Compare commits

..

61 Commits

Author SHA1 Message Date
Dotta
4cfbeaba9d Drop lockfile from watcher change 2026-03-14 12:19:25 -05:00
Dotta
0605c9f229 Tighten plugin dev file watching 2026-03-14 12:07:04 -05:00
Dotta
22b8e90ba6 Fix plugin smoke example typecheck 2026-03-14 11:44:50 -05:00
Dotta
7c4b02f02b Fix plugin dev watcher and migration snapshot 2026-03-14 11:32:15 -05:00
Dotta
eafb5b8fd9 Merge public-gh/master into feature/plugin-runtime-instance-cleanup 2026-03-14 10:46:19 -05:00
Dotta
30888759f2 Clarify plugin authoring and external dev workflow 2026-03-14 10:40:21 -05:00
Dotta
193a987513 Merge pull request #837 from paperclipai/paperclip-issue-documents
feat(issues): add issue documents and inline editing
2026-03-14 09:37:47 -05:00
Dotta
cb5d7e76fb Expand kitchen sink plugin demos 2026-03-14 09:26:45 -05:00
Dotta
bc12f08c66 fix(issue-documents): address greptile review
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 09:18:59 -05:00
Dotta
a7a64f11be Update packages/shared/src/validators/issue.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-14 09:09:21 -05:00
Dotta
31e6e30fe3 feat(ui): add issue document copy and download actions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 07:24:47 -05:00
Dotta
ad7bf4288a fix(ui): unify new issue upload action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 07:21:21 -05:00
Dotta
16dfcb56a4 feat(ui): stage issue files before create
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 07:13:59 -05:00
Dotta
924762c073 feat(ui): handle issue document edit conflicts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 06:59:20 -05:00
Dotta
abb70ca5c5 fix(ui): refresh issue documents from live events
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 06:52:44 -05:00
Dotta
1e3a485408 feat(ui): deep link issue documents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 06:48:43 -05:00
Dotta
07d13e1738 fix(ui): streamline issue document chrome
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 06:13:07 -05:00
Dotta
c8cd950a03 fix(ui): collapse empty document and attachment states
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 06:02:46 -05:00
Dotta
501ab4ffa9 fix(ui): simplify document card body layout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-14 05:56:17 -05:00
Dotta
6fa1dd2197 Add kitchen sink plugin example 2026-03-13 23:03:51 -05:00
Dotta
eb0a74384e fix(issues): address document review comments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 22:17:49 -05:00
Dotta
ab41fdbaee Merge public-gh/master into paperclip-issue-documents
Resolve conflicts by keeping the issue-documents work alongside upstream heartbeat-context, worktree branding, and adapter runtime updates.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 21:47:06 -05:00
Dotta
45998aa9a0 feat(issues): add issue documents and inline editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 21:30:48 -05:00
Dotta
12ccfc2c9a Simplify plugin runtime and cleanup lifecycle 2026-03-13 16:58:29 -05:00
Dotta
80cdbdbd47 Add plugin framework and settings UI 2026-03-13 16:22:34 -05:00
Dotta
bcce5b7ec2 Merge pull request #816 from paperclipai/fix/worktree-seed-and-env-quoting
fix(cli): preserve worktree seed source config and quote special env values
2026-03-13 15:18:03 -05:00
Dotta
8eacc9c697 Merge pull request #817 from paperclipai/docs/agent-evals-framework-plan
docs: add agent evals framework plan
2026-03-13 15:17:40 -05:00
Dotta
db81a06386 docs: add agent evals framework plan 2026-03-13 15:07:56 -05:00
Dotta
626a8f1976 fix(cli): quote env values with special characters 2026-03-13 15:07:49 -05:00
Dotta
aa799bba4c Fix worktree seed source selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 15:07:42 -05:00
Dotta
aaadbdc144 Merge pull request #790 from paperclipai/paperclip-token-optimization
Optimize heartbeat token usage
2026-03-13 15:01:45 -05:00
Dotta
a393db78b4 fix: address greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 14:53:30 -05:00
Dotta
c1430e7b06 docs: add paperclip skill tightening plan
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 14:37:44 -05:00
Dotta
7e288d20fc Merge remote-tracking branch 'public-gh/master' into pr-432
* public-gh/master:
  Add worktree UI branding
  Fix company switch remembered routes
  Add me and unassigned assignee options
  feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
  Fix manual company switch route sync
  Delay onboarding starter task creation until launch
2026-03-13 11:59:17 -05:00
Dotta
528505a04a fix: isolate codex home in worktrees 2026-03-13 11:53:56 -05:00
Dotta
e2a0347c6d Merge pull request #805 from paperclipai/fix/worktree-ui-branding
Add worktree UI branding
2026-03-13 11:15:11 -05:00
Dotta
cce9941464 Add worktree UI branding 2026-03-13 11:12:43 -05:00
Dotta
d51c4b1a4c fix: tighten token optimization edge cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 10:18:00 -05:00
Dotta
3b0d9a93f4 Merge pull request #802 from paperclipai/fix/ui-routing-and-assignee-polish
fix(ui): polish company switching, issue tab order, and assignee filters
2026-03-13 10:11:09 -05:00
Dotta
41eb8e51e3 Fix company switch remembered routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 10:01:32 -05:00
Dotta
cdebf7b538 Merge remote-tracking branch 'public-gh/master' into pr-432
* public-gh/master: (33 commits)
  fix: align embedded postgres ctor types with initdbFlags usage
  docs: add dated plan naming rule and align workspace plan
  Expand workspace plan for migration and cloud execution
  Add workspace product model plan
  docs: add token optimization plan
  docs: organize plans into doc/plans with date prefixes
  fix: keep runtime skills scoped to ./skills
  fix: prefer .agents skills and repair codex symlink targets\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing>
  Change sidebar Documentation link to external docs.paperclip.ing
  Fix local-cli skill install for moved .agents skills
  docs: update PRODUCT.md and add 2026-03-13 features plan
  feat(worktree): add worktree:cleanup command, env var defaults, and auto-prefix
  fix: resolve type errors in process-lost-reaper PR
  fix(heartbeat): prevent false process_lost failures on queued and non-child-process runs
  Revert "Merge pull request #707 from paperclipai/nm/premerge-lockfile-refresh"
  ci: refresh pnpm lockfile before merge
  fix(docker): include gemini adapter manifest in deps stage
  chore(lockfile): refresh pnpm-lock.yaml
  Raise default max turns to 300
  Drop pnpm lockfile from PR
  ...
2026-03-13 09:52:38 -05:00
Dotta
32ab4f8e47 Add me and unassigned assignee options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
6365e03731 feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
When creating a new issue with a pre-filled assignee or project (e.g. from
a project page), Tab from the title field now skips over fields that already
have values, going directly to the next empty field or description.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
2b9de934e3 Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
4a368f54d5 Delay onboarding starter task creation until launch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:46:36 -05:00
Dotta
7d1748b3a7 feat: optimize heartbeat token usage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
2246d5f1eb Add me and unassigned assignee options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
575a2fd83f feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
When creating a new issue with a pre-filled assignee or project (e.g. from
a project page), Tab from the title field now skips over fields that already
have values, going directly to the next empty field or description.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
c9259bbec0 Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
f3c18db7dd Delay onboarding starter task creation until launch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
43baf709dd Merge pull request #797 from paperclipai/fix/embedded-postgres-initdbflags
fix: align embedded postgres ctor types with initdbFlags usage
2026-03-13 09:39:27 -05:00
Dotta
0f3e9937f6 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  docs: update PRODUCT.md and add 2026-03-13 features plan
  feat(worktree): add worktree:cleanup command, env var defaults, and auto-prefix
2026-03-13 07:26:49 -05:00
Dotta
e24a116943 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  fix: resolve type errors in process-lost-reaper PR
  fix(heartbeat): prevent false process_lost failures on queued and non-child-process runs
  Revert "Merge pull request #707 from paperclipai/nm/premerge-lockfile-refresh"
  fix: ensure embedded PostgreSQL databases use UTF-8 encoding
2026-03-13 07:07:34 -05:00
Dotta
83381f9c12 Add me and unassigned assignee options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 16:12:38 -05:00
Dotta
f7e1952a55 feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
When creating a new issue with a pre-filled assignee or project (e.g. from
a project page), Tab from the title field now skips over fields that already
have values, going directly to the next empty field or description.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 16:11:37 -05:00
Dotta
cf77ff927f Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 16:04:28 -05:00
Dotta
fc8b5e3956 fix: keep runtime skills scoped to ./skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 15:57:37 -05:00
Dotta
ed16d30afc fix: prefer .agents skills and repair codex symlink targets\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing> 2026-03-12 15:44:44 -05:00
Dotta
402cef66e9 Change sidebar Documentation link to external docs.paperclip.ing
The sidebar Documentation links were pointing to an internal /docs route.
Updated both mobile and desktop sidebar instances to link to
https://docs.paperclip.ing/ in a new tab instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:39:50 -05:00
Dotta
13c2ecd1d0 Delay onboarding starter task creation until launch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 14:37:30 -05:00
Paperclip
a2b7611d8d Fix local-cli skill install for moved .agents skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 14:33:15 -05:00
211 changed files with 58427 additions and 621 deletions

View File

@@ -4,7 +4,9 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ensureAgentJwtSecret,
mergePaperclipEnvEntries,
readAgentJwtSecretFromEnv,
readPaperclipEnvEntries,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
@@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => {
const result = agentJwtSecretCheck(configPath);
expect(result.status).toBe("pass");
});
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
mergePaperclipEnvEntries(
{
PAPERCLIP_WORKTREE_COLOR: "#439edb",
},
envPath,
);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
});

View File

@@ -7,6 +7,7 @@ import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
rebindWorkspaceCwd,
resolveSourceConfigPath,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
@@ -16,6 +17,7 @@ import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
generateWorktreeColor,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
@@ -181,13 +183,22 @@ describe("worktree helpers", () => {
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths);
const env = buildWorktreeEnvEntries(paths, {
name: "feature-worktree-support",
color: "#3abf7a",
});
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("generates vivid worktree colors as hex", () => {
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
@@ -280,7 +291,10 @@ describe("worktree helpers", () => {
});
const envPath = path.join(repoRoot, ".paperclip", ".env");
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
const envContents = fs.readFileSync(envPath, "utf8");
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
} finally {
process.chdir(originalCwd);
if (originalJwtSecret === undefined) {
@@ -292,6 +306,59 @@ describe("worktree helpers", () => {
}
});
it("defaults the seed source config to the current repo-local Paperclip config", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
const repoRoot = path.join(tempRoot, "repo");
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("preserves the source config path across worktree:make cwd changes", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetRoot = path.join(tempRoot, "target");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(targetRoot);
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
path.resolve(sourceConfigPath),
);
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({

View File

@@ -1,3 +1,4 @@
import { randomInt } from "node:crypto";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import { expandHomePrefix } from "../config/home.js";
@@ -44,6 +45,11 @@ export type WorktreeLocalPaths = {
storageDir: string;
};
export type WorktreeUiBranding = {
name: string;
color: string;
};
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
}
@@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string)
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
}
function hslComponentToHex(n: number): string {
return Math.round(Math.max(0, Math.min(255, n)))
.toString(16)
.padStart(2, "0");
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const s = Math.max(0, Math.min(100, saturation)) / 100;
const l = Math.max(0, Math.min(100, lightness)) / 100;
const c = (1 - Math.abs((2 * l) - 1)) * s;
const h = ((hue % 360) + 360) % 360;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - (c / 2);
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
}
export function generateWorktreeColor(): string {
return hslToHex(randomInt(0, 360), 68, 56);
}
export function resolveWorktreeLocalPaths(opts: {
cwd: string;
homeDir?: string;
@@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: {
};
}
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
export function buildWorktreeEnvEntries(
paths: WorktreeLocalPaths,
branding?: WorktreeUiBranding,
): Record<string, string> {
return {
PAPERCLIP_HOME: paths.homeDir,
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
};
}

View File

@@ -39,6 +39,7 @@ import {
buildWorktreeEnvEntries,
DEFAULT_WORKTREE_HOME,
formatShellExports,
generateWorktreeColor,
isWorktreeSeedMode,
resolveSuggestedWorktreeName,
resolveWorktreeSeedPlan,
@@ -55,6 +56,7 @@ type WorktreeInitOptions = {
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
sourceConfigPathOverride?: string;
serverPort?: number;
dbPort?: number;
seed?: boolean;
@@ -425,8 +427,12 @@ async function rebindSeededProjectWorkspaces(input: {
}
}
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride);
if (opts.fromConfig) return path.resolve(opts.fromConfig);
if (!opts.fromDataDir && !opts.fromInstance) {
return resolveConfigPath();
}
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
@@ -623,7 +629,7 @@ async function seedWorktreeDatabase(input: {
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
const worktreeName = resolveSuggestedWorktreeName(
cwd,
opts.name ?? detectGitBranchName(cwd) ?? undefined,
);
@@ -631,12 +637,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName);
const paths = resolveWorktreeLocalPaths({
cwd,
homeDir: resolveWorktreeHome(opts.home),
instanceId,
});
const branding = {
name: worktreeName,
color: generateWorktreeColor(),
};
const sourceConfigPath = resolveSourceConfigPath(opts);
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
@@ -669,7 +679,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
mergePaperclipEnvEntries(
{
...buildWorktreeEnvEntries(paths),
...buildWorktreeEnvEntries(paths, branding),
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
},
paths.envPath,
@@ -710,6 +720,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`));
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
if (copiedGitHooks?.copied) {
p.log.message(
@@ -745,6 +756,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
const name = resolveWorktreeMakeName(nameArg);
const startPoint = resolveWorktreeStartPoint(opts.startPoint);
const sourceCwd = process.cwd();
const sourceConfigPath = resolveSourceConfigPath(opts);
const targetPath = resolveWorktreeMakeTargetPath(name);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists: ${targetPath}`);
@@ -804,6 +816,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
await runWorktreeInit({
...opts,
name,
sourceConfigPathOverride: sourceConfigPath,
});
} catch (error) {
throw error;

View File

@@ -22,11 +22,18 @@ function parseEnvFile(contents: string) {
}
}
function formatEnvValue(value: string): string {
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
function renderEnvFile(entries: Record<string, string>) {
const lines = [
"# Paperclip environment variables",
"# Generated by Paperclip CLI commands",
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
"",
];
return lines.join("\n");

View File

@@ -142,7 +142,7 @@ This command:
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
- picks a free app port and embedded PostgreSQL port
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot
Seed modes:
@@ -152,7 +152,13 @@ Seed modes:
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
That repo-local env also sets:
- `PAPERCLIP_IN_WORKTREE=true`
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
Print shell exports explicitly when needed:

View File

@@ -330,6 +330,34 @@ Operational policy:
- `asset_id` uuid fk not null
- `issue_comment_id` uuid fk null
## 7.15 `documents` + `document_revisions` + `issue_documents`
- `documents` stores editable text-first documents:
- `id` uuid pk
- `company_id` uuid fk not null
- `title` text null
- `format` text not null (`markdown`)
- `latest_body` text not null
- `latest_revision_id` uuid null
- `latest_revision_number` int not null
- `created_by_agent_id` uuid fk null
- `created_by_user_id` uuid/text fk null
- `updated_by_agent_id` uuid fk null
- `updated_by_user_id` uuid/text fk null
- `document_revisions` stores append-only history:
- `id` uuid pk
- `company_id` uuid fk not null
- `document_id` uuid fk not null
- `revision_number` int not null
- `body` text not null
- `change_summary` text null
- `issue_documents` links documents to issues with a stable workflow key:
- `id` uuid pk
- `company_id` uuid fk not null
- `issue_id` uuid fk not null
- `document_id` uuid fk not null
- `key` text not null (`plan`, `design`, `notes`, etc.)
## 8. State Machines
## 8.1 Agent Status
@@ -441,6 +469,11 @@ All endpoints are under `/api` and return JSON.
- `POST /companies/:companyId/issues`
- `GET /issues/:issueId`
- `PATCH /issues/:issueId`
- `GET /issues/:issueId/documents`
- `GET /issues/:issueId/documents/:key`
- `PUT /issues/:issueId/documents/:key`
- `GET /issues/:issueId/documents/:key/revisions`
- `DELETE /issues/:issueId/documents/:key`
- `POST /issues/:issueId/checkout`
- `POST /issues/:issueId/release`
- `POST /issues/:issueId/comments`

View File

@@ -118,10 +118,18 @@ Result:
Local adapters inject repo skills into runtime skill directories.
Important `codex_local` nuance:
- Codex does not read skills directly from the active worktree.
- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`.
- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it.
- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land.
- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested.
Current repo skill sizes:
- `skills/paperclip/SKILL.md`: 17,441 bytes
- `skills/create-agent-adapter/SKILL.md`: 31,832 bytes
- `.agents/skills/create-agent-adapter/SKILL.md`: 31,832 bytes
- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes
- `skills/para-memory-files/SKILL.md`: 3,978 bytes
@@ -215,6 +223,8 @@ This is the right version of the discussions bootstrap idea.
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent.
### Success criteria
- fresh-session prompts can remain richer without inflating every resumed heartbeat
@@ -305,6 +315,9 @@ Even when reuse is desirable, some sessions become too expensive to keep alive i
- `para-memory-files`
- `create-agent-adapter`
- Expose active skill set in agent config and run metadata.
- For `codex_local`, either:
- run with a worktree-specific `CODEX_HOME`, or
- treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout
### Why
@@ -363,6 +376,7 @@ Initial targets:
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
7. Add session rotation with carry-forward summaries.
8. Replace global skill injection with explicit allowlists.
9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime.
## Recommendation

View File

@@ -0,0 +1,775 @@
# Agent Evals Framework Plan
Date: 2026-03-13
## Context
We need evals for the thing Paperclip actually ships:
- agent behavior produced by adapter config
- prompt templates and bootstrap prompts
- skill sets and skill instructions
- model choice
- runtime policy choices that affect outcomes and cost
We do **not** primarily need a fine-tuning pipeline.
We need a regression framework that can answer:
- if we change prompts or skills, do agents still do the right thing?
- if we switch models, what got better, worse, or more expensive?
- if we optimize tokens, did we preserve task outcomes?
- can we grow the suite over time from real Paperclip usage?
This plan is based on:
- `doc/GOAL.md`
- `doc/PRODUCT.md`
- `doc/SPEC-implementation.md`
- `docs/agents-runtime.md`
- `doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
- Discussion #449: <https://github.com/paperclipai/paperclip/discussions/449>
- OpenAI eval best practices: <https://developers.openai.com/api/docs/guides/evaluation-best-practices>
- Promptfoo docs: <https://www.promptfoo.dev/docs/configuration/test-cases/> and <https://www.promptfoo.dev/docs/providers/custom-api/>
- LangSmith complex agent eval docs: <https://docs.langchain.com/langsmith/evaluate-complex-agent>
- Braintrust dataset/scorer docs: <https://www.braintrust.dev/docs/annotate/datasets> and <https://www.braintrust.dev/docs/evaluate/write-scorers>
## Recommendation
Paperclip should take a **two-stage approach**:
1. **Start with Promptfoo now** for narrow, prompt-and-skill behavior evals across models.
2. **Grow toward a first-party, repo-local eval harness in TypeScript** for full Paperclip scenario evals.
So the recommendation is no longer “skip Promptfoo.” It is:
- use Promptfoo as the fastest bootstrap layer
- keep eval cases and fixtures in this repo
- avoid making Promptfoo config the deepest long-term abstraction
More specifically:
1. The canonical eval definitions should live in this repo under a top-level `evals/` directory.
2. `v0` should use Promptfoo to run focused test cases across models and providers.
3. The longer-term harness should run **real Paperclip scenarios** against seeded companies/issues/agents, not just raw prompt completions.
4. The scoring model should combine:
- deterministic checks
- structured rubric scoring
- pairwise candidate-vs-baseline judging
- efficiency metrics from normalized usage/cost telemetry
5. The framework should compare **bundles**, not just models.
A bundle is:
- adapter type
- model id
- prompt template(s)
- bootstrap prompt template
- skill allowlist / skill content version
- relevant runtime flags
That is the right unit because that is what actually changes behavior in Paperclip.
## Why This Is The Right Shape
### 1. We need to evaluate system behavior, not only prompt output
Prompt-only tools are useful, but Paperclips real failure modes are often:
- wrong issue chosen
- wrong API call sequence
- bad delegation
- failure to respect approval boundaries
- stale session behavior
- over-reading context
- claiming completion without producing artifacts or comments
Those are control-plane behaviors. They require scenario setup, execution, and trace inspection.
### 2. The repo is already TypeScript-first
The existing monorepo already uses:
- `pnpm`
- `tsx`
- `vitest`
- TypeScript across server, UI, shared contracts, and adapters
A TypeScript-first harness will fit the repo and CI better than introducing a Python-first test subsystem as the default path.
Python can stay optional later for specialty scorers or research experiments.
### 3. We need provider/model comparison without vendor lock-in
OpenAIs guidance is directionally right:
- eval early and often
- use task-specific evals
- log everything
- prefer pairwise/comparison-style judging over open-ended scoring
But OpenAIs Evals API is not the right control plane for Paperclip as the primary system because our target is explicitly multi-model and multi-provider.
### 4. Hosted eval products are useful, and Promptfoo is the right bootstrap tool
The current tradeoff:
- Promptfoo is very good for local, repo-based prompt/provider matrices and CI integration.
- LangSmith is strong on trajectory-style agent evals.
- Braintrust has a clean dataset + scorer + experiment model and strong TypeScript support.
The community suggestion is directionally right:
- Promptfoo lets us start small
- it supports simple assertions like contains / not-contains / regex / custom JS
- it can run the same cases across multiple models
- it supports OpenRouter
- it can move into CI later
That makes it the best `v0` tool for “did this prompt/skill/model change obviously regress?”
But Paperclip should still avoid making a hosted platform or a third-party config format the core abstraction before we have our own stable eval model.
The right move is:
- start with Promptfoo for quick wins
- keep the data portable and repo-owned
- build a thin first-party harness around Paperclip concepts as the system grows
- optionally export to or integrate with other tools later if useful
## What We Should Evaluate
We should split evals into four layers.
### Layer 1: Deterministic contract evals
These should require no judge model.
Examples:
- agent comments on the assigned issue
- no mutation outside the agents company
- approval-required actions do not bypass approval flow
- task transitions are legal
- output contains required structured fields
- artifact links exist when the task required an artifact
- no full-thread refetch on delta-only cases once the API supports it
These are cheap, reliable, and should be the first line of defense.
### Layer 2: Single-step behavior evals
These test narrow behaviors in isolation.
Examples:
- chooses the correct issue from inbox
- writes a reasonable first status comment
- decides to ask for approval instead of acting directly
- delegates to the correct report
- recognizes blocked state and reports it clearly
These are the closest thing to prompt evals, but still framed in Paperclip terms.
### Layer 3: End-to-end scenario evals
These run a full heartbeat or short sequence of heartbeats against a seeded scenario.
Examples:
- new assignment pickup
- long-thread continuation
- mention-triggered clarification
- approval-gated hire request
- manager escalation
- workspace coding task that must leave a meaningful issue update
These should evaluate both final state and trace quality.
### Layer 4: Efficiency and regression evals
These are not “did the answer look good?” evals. They are “did we preserve quality while improving cost/latency?” evals.
Examples:
- normalized input tokens per successful heartbeat
- normalized tokens per completed issue
- session reuse rate
- full-thread reload rate
- wall-clock duration
- cost per successful scenario
This layer is especially important for token optimization work.
## Core Design
## 1. Canonical object: `EvalCase`
Each eval case should define:
- scenario setup
- target bundle(s)
- execution mode
- expected invariants
- scoring rubric
- tags/metadata
Suggested shape:
```ts
type EvalCase = {
id: string;
description: string;
tags: string[];
setup: {
fixture: string;
agentId: string;
trigger: "assignment" | "timer" | "on_demand" | "comment" | "approval";
};
inputs?: Record<string, unknown>;
checks: {
hard: HardCheck[];
rubric?: RubricCheck[];
pairwise?: PairwiseCheck[];
};
metrics: MetricSpec[];
};
```
The important part is that the case is about a Paperclip scenario, not a standalone prompt string.
## 2. Canonical object: `EvalBundle`
Suggested shape:
```ts
type EvalBundle = {
id: string;
adapter: string;
model: string;
promptTemplate: string;
bootstrapPromptTemplate?: string;
skills: string[];
flags?: Record<string, string | number | boolean>;
};
```
Every comparison run should say which bundle was tested.
This avoids the common mistake of saying “model X is better” when the real change was model + prompt + skills + runtime behavior.
## 3. Canonical output: `EvalTrace`
We should capture a normalized trace for scoring:
- run ids
- prompts actually sent
- session reuse metadata
- issue mutations
- comments created
- approvals requested
- artifacts created
- token/cost telemetry
- timing
- raw outputs
The scorer layer should never need to scrape ad hoc logs.
## Scoring Framework
## 1. Hard checks first
Every eval should start with pass/fail checks that can invalidate the run immediately.
Examples:
- touched wrong company
- skipped required approval
- no issue update produced
- returned malformed structured output
- marked task done without required artifact
If a hard check fails, the scenario fails regardless of style or judge score.
## 2. Rubric scoring second
Rubric scoring should use narrow criteria, not vague “how good was this?” prompts.
Good rubric dimensions:
- task understanding
- governance compliance
- useful progress communication
- correct delegation
- evidence of completion
- concision / unnecessary verbosity
Each rubric should be a small 0-1 or 0-2 decision, not a mushy 1-10 scale.
## 3. Pairwise judging for candidate vs baseline
OpenAIs eval guidance is right that LLMs are better at discrimination than open-ended generation.
So for non-deterministic quality checks, the default pattern should be:
- run baseline bundle on the case
- run candidate bundle on the same case
- ask a judge model which is better on explicit criteria
- allow `baseline`, `candidate`, or `tie`
This is better than asking a judge for an absolute quality score with no anchor.
## 4. Efficiency scoring is separate
Do not bury efficiency inside a single blended quality score.
Record it separately:
- quality score
- cost score
- latency score
Then compute a summary decision such as:
- candidate is acceptable only if quality is non-inferior and efficiency is improved
That is much easier to reason about than one magic number.
## Suggested Decision Rule
For PR gating:
1. No hard-check regressions.
2. No significant regression on required scenario pass rate.
3. No significant regression on key rubric dimensions.
4. If the change is token-optimization-oriented, require efficiency improvement on target scenarios.
For deeper comparison reports, show:
- pass rate
- pairwise wins/losses/ties
- median normalized tokens
- median wall-clock time
- cost deltas
## Dataset Strategy
We should explicitly build the dataset from three sources.
### 1. Hand-authored seed cases
Start here.
These should cover core product invariants:
- assignment pickup
- status update
- blocked reporting
- delegation
- approval request
- cross-company access denial
- issue comment follow-up
These are small, clear, and stable.
### 2. Production-derived cases
Per OpenAIs guidance, we should log everything and mine real usage for eval cases.
Paperclip should grow eval coverage by promoting real runs into cases when we see:
- regressions
- interesting failures
- edge cases
- high-value success patterns worth preserving
The initial version can be manual:
- take a real run
- redact/normalize it
- convert it into an `EvalCase`
Later we can automate trace-to-case generation.
### 3. Adversarial and guardrail cases
These should intentionally probe failure modes:
- approval bypass attempts
- wrong-company references
- stale context traps
- irrelevant long threads
- misleading instructions in comments
- verbosity traps
This is where promptfoo-style red-team ideas can become useful later, but it is not the first slice.
## Repo Layout
Recommended initial layout:
```text
evals/
README.md
promptfoo/
promptfooconfig.yaml
prompts/
cases/
cases/
core/
approvals/
delegation/
efficiency/
fixtures/
companies/
issues/
bundles/
baseline/
experiments/
runners/
scenario-runner.ts
compare-runner.ts
scorers/
hard/
rubric/
pairwise/
judges/
rubric-judge.ts
pairwise-judge.ts
lib/
types.ts
traces.ts
metrics.ts
reports/
.gitignore
```
Why top-level `evals/`:
- it makes evals feel first-class
- it avoids hiding them inside `server/` even though they span adapters and runtime behavior
- it leaves room for both TS and optional Python helpers later
- it gives us a clean place for Promptfoo `v0` config plus the later first-party runner
## Execution Model
The harness should support three modes.
### Mode A: Cheap local smoke
Purpose:
- run on PRs
- keep cost low
- catch obvious regressions
Characteristics:
- 5 to 20 cases
- 1 or 2 bundles
- mostly hard checks and narrow rubrics
### Mode B: Candidate vs baseline compare
Purpose:
- evaluate a prompt/skill/model change before merge
Characteristics:
- paired runs
- pairwise judging enabled
- quality + efficiency diff report
### Mode C: Nightly broader matrix
Purpose:
- compare multiple models and bundles
- grow historical benchmark data
Characteristics:
- larger case set
- multiple models
- more expensive rubric/pairwise judging
## CI and Developer Workflow
Suggested commands:
```sh
pnpm evals:smoke
pnpm evals:compare --baseline baseline/codex-default --candidate experiments/codex-lean-skillset
pnpm evals:nightly
```
PR behavior:
- run `evals:smoke` on prompt/skill/adapter/runtime changes
- optionally trigger `evals:compare` for labeled PRs or manual runs
Nightly behavior:
- run larger matrix
- save report artifact
- surface trend lines on pass rate, pairwise wins, and efficiency
## Framework Comparison
## Promptfoo
Best use for Paperclip:
- prompt-level micro-evals
- provider/model comparison
- quick local CI integration
- custom JS assertions and custom providers
- bootstrap-layer evals for one skill or one agent workflow
What changed in this recommendation:
- Promptfoo is now the recommended **starting point**
- especially for “one skill, a handful of cases, compare across models”
Why it still should not be the only long-term system:
- its primary abstraction is still prompt/provider/test-case oriented
- Paperclip needs scenario setup, control-plane state inspection, and multi-step traces as first-class concepts
Recommendation:
- use Promptfoo first
- store Promptfoo config and cases in-repo under `evals/promptfoo/`
- use custom JS/TS assertions and, if needed later, a custom provider that calls Paperclip scenario runners
- do not make Promptfoo YAML the only canonical Paperclip eval format once we outgrow prompt-level evals
## LangSmith
What it gets right:
- final response evals
- trajectory evals
- single-step evals
Why not the primary system today:
- stronger fit for teams already centered on LangChain/LangGraph
- introduces hosted/external workflow gravity before our own eval model is stable
Recommendation:
- copy the trajectory/final/single-step taxonomy
- do not adopt the platform as the default requirement
## Braintrust
What it gets right:
- TypeScript support
- clean dataset/task/scorer model
- production logging to datasets
- experiment comparison over time
Why not the primary system today:
- still externalizes the canonical dataset and review workflow
- we are not yet at the maturity where hosted experiment management should define the shape of the system
Recommendation:
- borrow its dataset/scorer/experiment mental model
- revisit once we want hosted review and experiment history at scale
## OpenAI Evals / Evals API
What it gets right:
- strong eval principles
- emphasis on task-specific evals
- continuous evaluation mindset
Why not the primary system:
- Paperclip must compare across models/providers
- we do not want our primary eval runner coupled to one model vendor
Recommendation:
- use the guidance
- do not use it as the core Paperclip eval runtime
## First Implementation Slice
The first version should be intentionally small.
## Phase 0: Promptfoo bootstrap
Build:
- `evals/promptfoo/promptfooconfig.yaml`
- 5 to 10 focused cases for one skill or one agent workflow
- model matrix using the providers we care about most
- mostly deterministic assertions:
- contains
- not-contains
- regex
- custom JS assertions
Target scope:
- one skill, or one narrow workflow such as assignment pickup / first status update
- compare a small set of bundles across several models
Success criteria:
- we can run one command and compare outputs across models
- prompt/skill regressions become visible quickly
- the team gets signal before building heavier infrastructure
## Phase 1: Skeleton and core cases
Build:
- `evals/` scaffold
- `EvalCase`, `EvalBundle`, `EvalTrace` types
- scenario runner for seeded local cases
- 10 hand-authored core cases
- hard checks only
Target cases:
- assigned issue pickup
- write progress comment
- ask for approval when required
- respect company boundary
- report blocked state
- avoid marking done without artifact/comment evidence
Success criteria:
- a developer can run a local smoke suite
- prompt/skill changes can fail the suite deterministically
- Promptfoo `v0` cases either migrate into or coexist with this layer cleanly
## Phase 2: Pairwise and rubric layer
Build:
- rubric scorer interface
- pairwise judge runner
- candidate vs baseline compare command
- markdown/html report output
Success criteria:
- model/prompt bundle changes produce a readable diff report
- we can tell “better”, “worse”, or “same” on curated scenarios
## Phase 3: Efficiency integration
Build:
- normalized token/cost metrics into eval traces
- cost and latency comparisons
- efficiency gates for token optimization work
Dependency:
- this should align with the telemetry normalization work in `2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
Success criteria:
- quality and efficiency can be judged together
- token-reduction work no longer relies on anecdotal improvements
## Phase 4: Production-case ingestion
Build:
- tooling to promote real runs into new eval cases
- metadata tagging
- failure corpus growth process
Success criteria:
- the eval suite grows from real product behavior instead of staying synthetic
## Initial Case Categories
We should start with these categories:
1. `core.assignment_pickup`
2. `core.progress_update`
3. `core.blocked_reporting`
4. `governance.approval_required`
5. `governance.company_boundary`
6. `delegation.correct_report`
7. `threads.long_context_followup`
8. `efficiency.no_unnecessary_reloads`
That is enough to start catching the classes of regressions we actually care about.
## Important Guardrails
### 1. Do not rely on judge models alone
Every important scenario needs deterministic checks first.
### 2. Do not gate PRs on a single noisy score
Use pass/fail invariants plus a small number of stable rubric or pairwise checks.
### 3. Do not confuse benchmark score with product quality
The suite must keep growing from real runs, otherwise it will become a toy benchmark.
### 4. Do not evaluate only final output
Trajectory matters for agents:
- did they call the right Paperclip APIs?
- did they ask for approval?
- did they communicate progress?
- did they choose the right issue?
### 5. Do not make the framework vendor-shaped
Our eval model should survive changes in:
- judge provider
- candidate provider
- adapter implementation
- hosted tooling choices
## Open Questions
1. Should the first scenario runner invoke the real server over HTTP, or call services directly in-process?
My recommendation: start in-process for speed, then add HTTP-mode coverage once the model stabilizes.
2. Should we support Python scorers in v1?
My recommendation: no. Keep v1 all-TypeScript.
3. Should we commit baseline outputs?
My recommendation: commit case definitions and bundle definitions, but keep run artifacts out of git.
4. Should we add hosted experiment tracking immediately?
My recommendation: no. Revisit after the local harness proves useful.
## Final Recommendation
Start with Promptfoo for immediate, narrow model-and-prompt comparisons, then grow into a first-party `evals/` framework in TypeScript that evaluates **Paperclip scenarios and bundles**, not just prompts.
Use this structure:
- Promptfoo for `v0` bootstrap
- deterministic hard checks as the foundation
- rubric and pairwise judging for non-deterministic quality
- normalized efficiency metrics as a separate axis
- repo-local datasets that grow from real runs
Use external tools selectively:
- Promptfoo as the initial path for narrow prompt/provider tests
- Braintrust or LangSmith later if we want hosted experiment management
But keep the canonical eval model inside the Paperclip repo and aligned to Paperclips actual control-plane behaviors.

View File

@@ -0,0 +1,186 @@
# Paperclip Skill Tightening Plan
## Status
Deferred follow-up. Do not include in the current token-optimization PR beyond documenting the plan.
## Why This Is Deferred
The `paperclip` skill is part of the critical control-plane safety surface. Tightening it may reduce fresh-session token use, but it also carries prompt-regression risk. We do not yet have evals that would let us safely prove behavior preservation across assignment handling, checkout rules, comment etiquette, approval workflows, and escalation paths.
The current PR should ship the lower-risk infrastructure wins first:
- telemetry normalization
- safe session reuse
- incremental issue/comment context
- bootstrap versus heartbeat prompt separation
- Codex worktree isolation
## Current Problem
Fresh runs still spend substantial input tokens even after the context-path fixes. The remaining large startup cost appears to come from loading the full `paperclip` skill and related instruction surface into context at run start.
The skill currently mixes three kinds of content in one file:
- hot-path heartbeat procedure used on nearly every run
- critical policy and safety invariants
- rare workflow/reference material that most runs do not need
That structure is safe but expensive.
## Goals
- reduce first-run instruction tokens without weakening agent safety
- preserve all current Paperclip control-plane capabilities
- keep common heartbeat behavior explicit and easy for agents to follow
- move rare workflows and reference material out of the hot path
- create a structure that can later be evaluated systematically
## Non-Goals
- changing Paperclip API semantics
- removing required governance rules
- deleting rare workflows
- changing agent defaults in the current PR
## Recommended Direction
### 1. Split Hot Path From Lookup Material
Restructure the skill into:
- an always-loaded core section for the common heartbeat loop
- on-demand material for infrequent workflows and deep reference
The core should cover only what is needed on nearly every wake:
- auth and required headers
- inbox-first assignment retrieval
- mandatory checkout behavior
- `heartbeat-context` first
- incremental comment retrieval rules
- mention/self-assign exception
- blocked-task dedup
- status/comment/release expectations before exit
### 2. Normalize The Skill Around One Canonical Procedure
The same rules are currently expressed multiple times across:
- heartbeat steps
- critical rules
- endpoint reference
- workflow examples
Refactor so each operational fact has one primary home:
- procedure
- invariant list
- appendix/reference
This reduces prompt weight and lowers the chance of internal instruction drift.
### 3. Compress Prose Into High-Signal Instruction Forms
Rewrite the hot path using compact operational forms:
- short ordered checklist
- flat invariant list
- minimal examples only where ambiguity would be risky
Reduce:
- narrative explanation
- repeated warnings already covered elsewhere
- large example payloads for common operations
- long endpoint matrices in the main body
### 4. Move Rare Workflows Behind Explicit Triggers
These workflows should remain available but should not dominate fresh-run context:
- OpenClaw invite flow
- project setup flow
- planning `<plan/>` writeback flow
- instructions-path update flow
- detailed link-formatting examples
Recommended approach:
- keep a short pointer in the main skill
- move detailed procedures into sibling skills or referenced docs that agents read only when needed
### 5. Separate Policy From Reference
The skill should distinguish:
- mandatory operating rules
- endpoint lookup/reference
- business-process playbooks
That separation makes it easier to evaluate prompt changes later and lets adapters or orchestration choose what must always be loaded.
## Proposed Target Structure
1. Purpose and authentication
2. Compact heartbeat procedure
3. Hard invariants
4. Required comment/update style
5. Triggered workflow index
6. Appendix/reference
## Rollout Plan
### Phase 1. Inventory And Measure
- annotate the current skill by section and estimate token weight
- identify which sections are truly hot-path versus rare
- capture representative runs to compare before/after prompt size and behavior
### Phase 2. Structural Refactor Without Semantic Changes
- rewrite the main skill into the target structure
- preserve all existing rules and capabilities
- move rare workflow details into referenced companion material
- keep wording changes conservative
### Phase 3. Validate Against Real Scenarios
Run scenario checks for:
- normal assigned heartbeat
- comment-triggered wake
- blocked-task dedup behavior
- approval-resolution wake
- delegation/subtask creation
- board handoff back to user
- plan-request handling
### Phase 4. Decide Default Loading Strategy
After validation, decide whether:
- the entire main skill still loads by default, or
- only the compact core loads by default and rare sections are fetched on demand
Do not change this loading policy without validation.
## Risks
- prompt degradation on control-plane safety rules
- agents forgetting rare but important workflows
- accidental removal of repeated wording that was carrying useful behavior
- introducing ambiguous instruction precedence between the core skill and companion materials
## Preconditions Before Implementation
- define acceptance scenarios for control-plane correctness
- add at least lightweight eval or scripted scenario coverage for key Paperclip flows
- confirm how adapter/bootstrap layering should load skill content versus references
## Success Criteria
- materially lower first-run input tokens for Paperclip-coordinated agents
- no regression in checkout discipline, issue updates, blocked handling, or delegation
- no increase in malformed API usage or ownership mistakes
- agents still complete rare workflows correctly when explicitly asked

View File

@@ -0,0 +1,699 @@
# Kitchen Sink Plugin Plan
## Goal
Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place.
This plugin is meant to be:
- a living reference implementation for contributors
- a manual test harness for the plugin runtime
- a discoverable demo of what plugins can actually do today
It is not meant to be a polished end-user product plugin.
## Why
The current plugin system has a real API surface, but it is spread across:
- SDK docs
- SDK types
- plugin spec prose
- two example plugins that each show only a narrow slice
That makes it hard to answer basic questions like:
- what can plugins render?
- what can plugin workers actually do?
- which surfaces are real versus aspirational?
- how should a new plugin be structured in this repo?
The kitchen-sink plugin should answer those questions by example.
## Success Criteria
The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip.
Concretely:
- it installs from the bundled examples list
- it exposes at least one demo for every implemented worker API surface
- it exposes at least one demo for every host-mounted UI surface
- it clearly labels local-only / trusted-only demos
- it is safe enough for local development by default
- it doubles as a regression harness for plugin runtime changes
## Constraints
- Keep it instance-installed, not company-installed.
- Treat this as a trusted/local example plugin.
- Do not rely on cloud-safe runtime assumptions.
- Avoid destructive defaults.
- Avoid irreversible mutations unless they are clearly labeled and easy to undo.
## Source Of Truth For This Plan
This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec.
Primary references:
- `packages/plugins/sdk/README.md`
- `packages/plugins/sdk/src/types.ts`
- `packages/plugins/sdk/src/ui/types.ts`
- `packages/shared/src/constants.ts`
- `packages/shared/src/types/plugin.ts`
## Current Surface Inventory
### Worker/runtime APIs to demonstrate
These are the concrete `ctx` clients currently exposed by the SDK:
- `ctx.config`
- `ctx.events`
- `ctx.jobs`
- `ctx.launchers`
- `ctx.http`
- `ctx.secrets`
- `ctx.assets`
- `ctx.activity`
- `ctx.state`
- `ctx.entities`
- `ctx.projects`
- `ctx.companies`
- `ctx.issues`
- `ctx.agents`
- `ctx.goals`
- `ctx.data`
- `ctx.actions`
- `ctx.streams`
- `ctx.tools`
- `ctx.metrics`
- `ctx.logger`
### UI surfaces to demonstrate
Surfaces defined in the SDK:
- `page`
- `settingsPage`
- `dashboardWidget`
- `sidebar`
- `sidebarPanel`
- `detailTab`
- `taskDetailView`
- `projectSidebarItem`
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
- `commentContextMenuItem`
### Current host confidence
Confirmed or strongly indicated as mounted in the current app:
- `page`
- `settingsPage`
- `dashboardWidget`
- `detailTab`
- `projectSidebarItem`
- comment surfaces
- launcher infrastructure
Need explicit validation before claiming full demo coverage:
- `sidebar`
- `sidebarPanel`
- `taskDetailView`
- `toolbarButton` as direct slot, distinct from launcher placement
- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement
The implementation should keep a small validation checklist for these before we call the plugin "complete".
## Plugin Concept
The plugin should be named:
- display name: `Kitchen Sink (Example)`
- package: `@paperclipai/plugin-kitchen-sink-example`
- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example`
Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style.
Category mix:
- `ui`
- `automation`
- `workspace`
- `connector`
That is intentionally broad because the point is coverage.
## UX Shape
The plugin should have one main full-page demo console plus smaller satellites on other surfaces.
### 1. Plugin page
Primary route: the plugin `page` surface should be the central dashboard for all demos.
Recommended page sections:
- `Overview`
- what this plugin demonstrates
- current capabilities granted
- current host context
- `UI Surfaces`
- links explaining where each other surface should appear
- `Data + Actions`
- buttons and forms for bridge-driven worker demos
- `Events + Streams`
- emit event
- watch event log
- stream demo output
- `Paperclip Domain APIs`
- companies
- projects/workspaces
- issues
- goals
- agents
- `Local Workspace + Process`
- file listing
- file read/write scratch area
- child process demo
- `Jobs + Webhooks + Tools`
- job status
- webhook URL and recent deliveries
- declared tools
- `State + Entities + Assets`
- scoped state editor
- plugin entity inspector
- upload/generated asset demo
- `Observability`
- metrics written
- activity log samples
- latest worker logs
### 2. Dashboard widget
A compact widget on the main dashboard should show:
- plugin health
- count of demos exercised
- recent event/stream activity
- shortcut to the full plugin page
### 3. Project sidebar item
Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab.
### 4. Detail tabs
Use detail tabs to demonstrate entity-context rendering on:
- `project`
- `issue`
- `agent`
- `goal`
Each tab should show:
- the host context it received
- the relevant entity fetch via worker bridge
- one small action scoped to that entity
### 5. Comment surfaces
Use issue comment demos to prove comment-specific extension points:
- `commentAnnotation`
- render parsed metadata below each comment
- show comment id, issue id, and a small derived status
- `commentContextMenuItem`
- add a menu action like `Copy Context To Kitchen Sink`
- action writes a plugin entity or state record for later inspection
### 6. Settings page
Custom `settingsPage` should be intentionally simple and operational:
- `About`
- `Danger / Trust Model`
- demo toggles
- local process defaults
- workspace scratch-path behavior
- secret reference inputs
- event/job/webhook sample config
This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics.
## Feature Matrix
Each implemented worker API should have a visible demo.
### `ctx.config`
Demo:
- read live config
- show config JSON
- react to config changes without restart where possible
### `ctx.events`
Demos:
- emit a plugin event
- subscribe to plugin events
- subscribe to a core Paperclip event such as `issue.created`
- show recent received events in a timeline
### `ctx.jobs`
Demos:
- one scheduled heartbeat-style demo job
- one manual run button from the UI if host supports manual job trigger
- show last run result and timestamps
### `ctx.launchers`
Demos:
- declare launchers in manifest
- optionally register one runtime launcher from the worker
- show launcher metadata on the plugin page
### `ctx.http`
Demo:
- make a simple outbound GET request to a safe endpoint
- show status code, latency, and JSON result
Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs.
### `ctx.secrets`
Demo:
- operator enters a secret reference in config
- plugin resolves it on demand
- UI only shows masked result length / success status, never raw secret
### `ctx.assets`
Demos:
- generate a text asset from the UI
- optionally upload a tiny JSON blob or screenshot-like text file
- show returned asset URL
### `ctx.activity`
Demo:
- button to write a plugin activity log entry against current company/entity
### `ctx.state`
Demos:
- instance-scoped state
- company-scoped state
- project-scoped state
- issue-scoped state
- delete/reset controls
Use a small state inspector/editor on the plugin page.
### `ctx.entities`
Demos:
- create plugin-owned sample records
- list/filter them
- show one realistic use case such as "copied comments" or "demo sync records"
### `ctx.projects`
Demos:
- list projects
- list project workspaces
- resolve primary workspace
- resolve workspace for issue
### `ctx.companies`
Demo:
- list companies and show current selected company
### `ctx.issues`
Demos:
- list issues in current company
- create issue
- update issue status/title
- list comments
- create comment
### `ctx.agents`
Demos:
- list agents
- invoke one agent with a test prompt
- pause/resume where safe
Agent mutation controls should be behind an explicit warning.
### `ctx.agents.sessions`
Demos:
- create agent chat session
- send message
- stream events back to the UI
- close session
This is a strong candidate for the best "wow" demo on the plugin page.
### `ctx.goals`
Demos:
- list goals
- create goal
- update status/title
### `ctx.data`
Use throughout the plugin for all read-side bridge demos.
### `ctx.actions`
Use throughout the plugin for all mutation-side bridge demos.
### `ctx.streams`
Demos:
- live event log stream
- token-style stream from an agent session relay
- fake progress stream for a long-running action
### `ctx.tools`
Demos:
- declare 2-3 simple agent tools
- tool 1: echo/diagnostics
- tool 2: project/workspace summary
- tool 3: create issue or write plugin state
The plugin page should list declared tools and show example input payloads.
### `ctx.metrics`
Demo:
- write a sample metric on each major demo action
- surface a small recent metrics table in the plugin page
### `ctx.logger`
Demo:
- every action logs structured entries
- plugin settings `Status` page then doubles as the log viewer
## Local Workspace And Process Demos
The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata.
The kitchen-sink plugin should demonstrate that explicitly.
### Workspace demos
- list files from a selected workspace
- read a file
- write to a plugin-owned scratch file
- optionally search files with `rg` if available
### Process demos
- run a short-lived command like `pwd`, `ls`, or `git status`
- stream stdout/stderr back to UI
- show exit code and timing
Important safeguards:
- default commands must be read-only
- no shell interpolation from arbitrary free-form input in v1
- provide a curated command list or a strongly validated command form
- clearly label this area as local-only and trusted-only
## Proposed Manifest Coverage
The plugin should aim to declare:
- `page`
- `settingsPage`
- `dashboardWidget`
- `detailTab` for `project`, `issue`, `agent`, `goal`
- `projectSidebarItem`
- `commentAnnotation`
- `commentContextMenuItem`
Then, after host validation, add if supported:
- `sidebar`
- `sidebarPanel`
- `taskDetailView`
- `toolbarButton`
- `contextMenuItem`
It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering.
## Proposed Package Layout
New package:
- `packages/plugins/examples/plugin-kitchen-sink-example/`
Expected files:
- `package.json`
- `README.md`
- `tsconfig.json`
- `src/index.ts`
- `src/manifest.ts`
- `src/worker.ts`
- `src/ui/index.tsx`
- `src/ui/components/...`
- `src/ui/hooks/...`
- `src/lib/...`
- optional `scripts/build-ui.mjs` if UI bundling needs esbuild
## Proposed Internal Architecture
### Worker modules
Recommended split:
- `src/worker.ts`
- plugin definition and wiring
- `src/worker/data.ts`
- `ctx.data.register(...)`
- `src/worker/actions.ts`
- `ctx.actions.register(...)`
- `src/worker/events.ts`
- event subscriptions and event log buffer
- `src/worker/jobs.ts`
- scheduled job handlers
- `src/worker/tools.ts`
- tool declarations and handlers
- `src/worker/local-runtime.ts`
- file/process demos
- `src/worker/demo-store.ts`
- helpers for state/entities/assets/metrics
### UI modules
Recommended split:
- `src/ui/index.tsx`
- exported slot components
- `src/ui/page/KitchenSinkPage.tsx`
- `src/ui/settings/KitchenSinkSettingsPage.tsx`
- `src/ui/widgets/KitchenSinkDashboardWidget.tsx`
- `src/ui/tabs/ProjectKitchenSinkTab.tsx`
- `src/ui/tabs/IssueKitchenSinkTab.tsx`
- `src/ui/tabs/AgentKitchenSinkTab.tsx`
- `src/ui/tabs/GoalKitchenSinkTab.tsx`
- `src/ui/comments/KitchenSinkCommentAnnotation.tsx`
- `src/ui/comments/KitchenSinkCommentMenuItem.tsx`
- `src/ui/shared/...`
## Configuration Schema
The plugin should have a substantial but understandable `instanceConfigSchema`.
Recommended config fields:
- `enableDangerousDemos`
- `enableWorkspaceDemos`
- `enableProcessDemos`
- `showSidebarEntry`
- `showSidebarPanel`
- `showProjectSidebarItem`
- `showCommentAnnotation`
- `showCommentContextMenuItem`
- `showToolbarLauncher`
- `defaultDemoCompanyId` optional
- `secretRefExample`
- `httpDemoUrl`
- `processAllowedCommands`
- `workspaceScratchSubdir`
Defaults should keep risky behavior off.
## Safety Defaults
Default posture:
- UI and read-only demos on
- mutating domain demos on but explicitly labeled
- process demos off by default
- no arbitrary shell input by default
- no raw secret rendering ever
## Phased Build Plan
### Phase 1: Core plugin skeleton
- scaffold package
- add manifest, worker, UI entrypoints
- add README
- make it appear in bundled examples list
### Phase 2: Core, confirmed UI surfaces
- plugin page
- settings page
- dashboard widget
- project sidebar item
- detail tabs
### Phase 3: Core worker APIs
- config
- state
- entities
- companies/projects/issues/goals
- data/actions
- metrics/logger/activity
### Phase 4: Real-time and automation APIs
- streams
- events
- jobs
- webhooks
- agent sessions
- tools
### Phase 5: Local trusted runtime demos
- workspace file demos
- child process demos
- guarded by config
### Phase 6: Secondary UI surfaces
- comment annotation
- comment context menu item
- launchers
### Phase 7: Validation-only surfaces
Validate whether the current host truly mounts:
- `sidebar`
- `sidebarPanel`
- `taskDetailView`
- direct-slot `toolbarButton`
- direct-slot `contextMenuItem`
If mounted, add demos.
If not mounted, document them as SDK-defined but host-pending.
## Documentation Deliverables
The plugin should ship with a README that includes:
- what it demonstrates
- which surfaces are local-only
- how to install it
- where each UI surface should appear
- a mapping from demo card to SDK API
It should also be referenced from plugin docs as the "reference everything plugin".
## Testing And Verification
Minimum verification:
- package typecheck/build
- install from bundled example list
- page loads
- widget appears
- project tab appears
- comment surfaces render
- settings page loads
- key actions succeed
Recommended manual checklist:
- create issue from plugin
- create goal from plugin
- emit and receive plugin event
- stream action output
- open agent session and receive streamed reply
- upload an asset
- write plugin activity log
- run a safe local process demo
## Open Questions
1. Should the process demo remain curated-command-only in the first pass?
Recommendation: yes.
2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically?
Recommendation: no. Make creation explicit.
3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired?
Recommendation: yes, but label them as `SDK-defined / host validation pending`.
4. Should agent mutation demos include pause/resume by default?
Recommendation: probably yes, but behind a warning block.
5. Should this plugin be treated as a supported regression harness in CI later?
Recommendation: yes. Long term, this should be the plugin-runtime smoke test package.
## Recommended Next Step
If this plan looks right, the next implementation pass should start by building only:
- package skeleton
- page
- settings page
- dashboard widget
- one project detail tab
- one issue detail tab
- the basic worker/action/data/state/event scaffolding
That is enough to lock the architecture before filling in every demo surface.

View File

@@ -0,0 +1,154 @@
# Plugin Authoring Guide
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
## Current reality
- Treat plugin workers and plugin UI as trusted code.
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
- There is no host-provided shared React component kit for plugins yet.
- `ctx.assets` is not supported in the current runtime.
## Scaffold a plugin
Use the scaffold package:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
```
For a plugin that lives outside the Paperclip repo:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
--output /absolute/path/to/plugin-repos \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
That creates a package with:
- `src/manifest.ts`
- `src/worker.ts`
- `src/ui/index.tsx`
- `tests/plugin.spec.ts`
- `esbuild.config.mjs`
- `rollup.config.mjs`
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
## Recommended local workflow
From the generated plugin folder:
```bash
pnpm install
pnpm typecheck
pnpm test
pnpm build
```
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
Example:
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
```
## Supported alpha surface
Worker:
- config
- events
- jobs
- launchers
- http
- secrets
- activity
- state
- entities
- projects and project workspaces
- companies
- issues and comments
- agents and agent sessions
- goals
- data/actions
- streams
- tools
- metrics
- logger
UI:
- `usePluginData`
- `usePluginAction`
- `usePluginStream`
- `usePluginToast`
- `useHostContext`
- typed slot props from `@paperclipai/plugin-sdk/ui`
Mount surfaces currently wired in the host include:
- `page`
- `settingsPage`
- `dashboardWidget`
- `sidebar`
- `sidebarPanel`
- `detailTab`
- `taskDetailView`
- `projectSidebarItem`
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
- `commentContextMenuItem`
## Company routes
Plugins may declare a `page` slot with `routePath` to own a company route like:
```text
/:companyPrefix/<routePath>
```
Rules:
- `routePath` must be a single lowercase slug
- it cannot collide with reserved host routes
- it cannot duplicate another installed plugin page route
## Publishing guidance
- Use npm packages as the deployment artifact.
- Treat repo-local example installs as a development workflow only.
- Prefer keeping plugin UI self-contained inside the package.
- Do not rely on host design-system components or undocumented app internals.
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
## Verification before handoff
At minimum:
```bash
pnpm --filter <your-plugin-package> typecheck
pnpm --filter <your-plugin-package> test
pnpm --filter <your-plugin-package> build
```
If you changed host integration too, also run:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```

View File

@@ -8,6 +8,29 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea
This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md).
It is the full target architecture for the plugin system that should follow V1.
## Current implementation caveats
The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
Today, the practical deployment model is:
- single-tenant
- self-hosted
- single-node or otherwise filesystem-persistent
Current limitations to keep in mind:
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
- Published npm packages are the intended install artifact for deployed plugins.
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
## 1. Scope
This spec covers:
@@ -212,6 +235,8 @@ Suggested layout:
The package install directory and the plugin data directory are separate.
This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
## 8.2 Operator Commands
Paperclip should add CLI commands:
@@ -237,6 +262,8 @@ The install process is:
7. Start plugin worker and run health/validation.
8. Mark plugin `ready` or `error`.
For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
## 9. Load Order And Precedence
Load order must be deterministic.

View File

@@ -30,6 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh

View File

@@ -6,7 +6,7 @@ summary: Guide to building a custom adapter
Build a custom adapter to connect Paperclip to any agent runtime.
<Tip>
If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
</Tip>
## Package Structure

View File

@@ -1,9 +1,9 @@
---
title: Issues
summary: Issue CRUD, checkout/release, comments, and attachments
summary: Issue CRUD, checkout/release, comments, documents, and attachments
---
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments.
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments.
## List Issues
@@ -29,6 +29,12 @@ GET /api/issues/{issueId}
Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals).
The response also includes:
- `planDocument`: the full text of the issue document with key `plan`, when present
- `documentSummaries`: metadata for all linked issue documents
- `legacyPlanDocument`: a read-only fallback when the description still contains an old `<plan>` block
## Create Issue
```
@@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
## Documents
Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`.
### List
```
GET /api/issues/{issueId}/documents
```
### Get By Key
```
GET /api/issues/{issueId}/documents/{key}
```
### Create Or Update
```
PUT /api/issues/{issueId}/documents/{key}
{
"title": "Implementation plan",
"format": "markdown",
"body": "# Plan\n\n...",
"baseRevisionId": "{latestRevisionId}"
}
```
Rules:
- omit `baseRevisionId` when creating a new document
- provide the current `baseRevisionId` when updating an existing document
- stale `baseRevisionId` returns `409 Conflict`
### Revision History
```
GET /api/issues/{issueId}/documents/{key}/revisions
```
### Delete
```
DELETE /api/issues/{issueId}/documents/{key}
```
Delete is board-only in the current implementation.
## Attachments
### Upload

View File

@@ -0,0 +1,569 @@
# Issue Documents Plan
Status: Draft
Owner: Backend + UI + Agent Protocol
Date: 2026-03-13
Primary issue: `PAP-448`
## Summary
Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues.
The first required convention is a document with key `plan`.
This solves the immediate workflow problem in `PAP-448`:
- plans should stop living inside issue descriptions as `<plan>` blocks
- agents and board users should be able to create/update issue documents directly
- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents
- issue detail should render documents under the description
This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets.
## Recommended Product Shape
### Documents vs attachments vs artifacts
- **Documents**: editable text content with stable keys and revision history.
- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`).
- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files.
Recommendation:
- implement **issue documents now**
- keep existing attachments as-is
- defer full artifact unification until there is a second real consumer beyond issue documents + attachments
This keeps `PAP-448` focused while still fitting the larger artifact direction.
## Goals
1. Give issues first-class keyed documents, starting with `plan`.
2. Make documents editable by board users and same-company agents with issue access.
3. Preserve change history with append-only revisions.
4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats.
5. Replace the current `<plan>`-in-description convention in skills/docs.
6. Keep the design compatible with a future artifact/deliverables layer.
## Non-Goals
- full collaborative doc editing
- binary-file version history
- browser IDE or workspace editor
- full artifact-system implementation in the same change
- generalized polymorphic relations for every entity type on day one
## Product Decisions
### 1. Keyed issue documents
Each issue can have multiple documents. Each document relation has a stable key:
- `plan`
- `design`
- `notes`
- `report`
- custom keys later
Key rules:
- unique per issue, case-insensitive
- normalized to lowercase slug form
- machine-oriented and stable
- title is separate and user-facing
The `plan` key is conventional and reserved by Paperclip workflow/docs.
### 2. Text-first v1
V1 documents should be text-first, not arbitrary blobs.
Recommended supported formats:
- `markdown`
- `plain_text`
- `json`
- `html`
Recommendation:
- optimize UI for `markdown`
- allow raw editing for the others
- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents
### 3. Revision model
Every document update creates a new immutable revision.
The current document row stores the latest snapshot for fast reads.
### 4. Concurrency model
Do not use silent last-write-wins.
Updates should include `baseRevisionId`:
- create: no base revision required
- update: `baseRevisionId` must match current latest revision
- mismatch: return `409 Conflict`
This is important because both board users and agents may edit the same document.
### 5. Issue fetch behavior
`GET /api/issues/:id` should include:
- full `planDocument` when a `plan` document exists
- `documentSummaries` for all linked documents
It should not inline every document body by default.
This keeps issue fetches useful for agents without making every issue payload unbounded.
### 6. Legacy `<plan>` compatibility
If an issue has no `plan` document but its description contains a legacy `<plan>` block:
- expose that as a legacy read-only fallback in API/UI
- mark it as legacy/synthetic
- prefer a real `plan` document when both exist
Recommendation:
- do not auto-rewrite old issue descriptions in the first rollout
- provide an explicit import/migrate path later
## Proposed Data Model
Recommendation: make documents first-class, but keep issue linkage explicit via a join table.
This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later.
## Tables
### `documents`
Canonical text document record.
Suggested columns:
- `id`
- `company_id`
- `title`
- `format`
- `latest_body`
- `latest_revision_id`
- `latest_revision_number`
- `created_by_agent_id`
- `created_by_user_id`
- `updated_by_agent_id`
- `updated_by_user_id`
- `created_at`
- `updated_at`
### `document_revisions`
Append-only history.
Suggested columns:
- `id`
- `company_id`
- `document_id`
- `revision_number`
- `body`
- `change_summary`
- `created_by_agent_id`
- `created_by_user_id`
- `created_at`
Constraints:
- unique `(document_id, revision_number)`
### `issue_documents`
Issue relation + workflow key.
Suggested columns:
- `id`
- `company_id`
- `issue_id`
- `document_id`
- `key`
- `created_at`
- `updated_at`
Constraints:
- unique `(company_id, issue_id, key)`
- unique `(document_id)` to keep one issue relation per document in v1
## Why not use `assets` for this?
Because `assets` solves blob storage, not:
- stable keyed semantics like `plan`
- inline text editing
- revision history
- optimistic concurrency
- cheap inclusion in `GET /issues/:id`
Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model.
## Shared Types and API Contract
## New shared types
Add:
- `DocumentFormat`
- `IssueDocument`
- `IssueDocumentSummary`
- `DocumentRevision`
Recommended `IssueDocument` shape:
```ts
type DocumentFormat = "markdown" | "plain_text" | "json" | "html";
interface IssueDocument {
id: string;
companyId: string;
issueId: string;
key: string;
title: string | null;
format: DocumentFormat;
body: string;
latestRevisionId: string;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
```
Recommended `IssueDocumentSummary` shape:
```ts
interface IssueDocumentSummary {
id: string;
key: string;
title: string | null;
format: DocumentFormat;
latestRevisionId: string;
latestRevisionNumber: number;
updatedAt: Date;
}
```
## Issue type enrichment
Extend `Issue` with:
```ts
interface Issue {
...
planDocument?: IssueDocument | null;
documentSummaries?: IssueDocumentSummary[];
legacyPlanDocument?: {
key: "plan";
body: string;
source: "issue_description";
} | null;
}
```
This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches.
## API endpoints
Recommended endpoints:
- `GET /api/issues/:issueId/documents`
- `GET /api/issues/:issueId/documents/:key`
- `PUT /api/issues/:issueId/documents/:key`
- `GET /api/issues/:issueId/documents/:key/revisions`
- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1
Recommended `PUT` body:
```ts
{
title?: string | null;
format: "markdown" | "plain_text" | "json" | "html";
body: string;
changeSummary?: string | null;
baseRevisionId?: string | null;
}
```
Behavior:
- missing document + no `baseRevisionId`: create
- existing document + matching `baseRevisionId`: update
- existing document + stale `baseRevisionId`: `409`
## Authorization and invariants
- all document records are company-scoped
- issue relation must belong to same company
- board access follows existing issue access rules
- agent access follows existing same-company issue access rules
- every mutation writes activity log entries
Recommended delete rule for v1:
- board can delete documents
- agents can create/update, but not delete
That keeps automated systems from removing canonical docs too easily.
## UI Plan
## Issue detail
Add a new **Documents** section directly under the issue description.
Recommended behavior:
- show `plan` first when present
- show other documents below it
- render a gist-like header:
- key
- title
- last updated metadata
- revision number
- support inline edit
- support create new document by key
- support revision history drawer or sheet
Recommended presentation order:
1. Description
2. Documents
3. Attachments
4. Comments / activity / sub-issues
This matches the request that documents live under the description while still leaving attachments available.
## Editing UX
Recommendation:
- use markdown preview + raw edit toggle for markdown docs
- use raw textarea editor for non-markdown docs in v1
- show explicit save conflicts on `409`
- show a clear empty state: "No documents yet"
## Legacy plan rendering
If there is no stored `plan` document but legacy `<plan>` exists:
- show it in the Documents section
- mark it `Legacy plan from description`
- offer create/import in a later pass
## Agent Protocol and Skills
Update the Paperclip agent workflow so planning no longer edits the issue description.
Required changes:
- update `skills/paperclip/SKILL.md`
- replace the `<plan>` instructions with document creation/update instructions
- document the new endpoints in `docs/api/issues.md`
- update any internal planning docs that still teach inline `<plan>` blocks
New rule:
- when asked to make a plan for an issue, create or update the issue document with key `plan`
- leave a comment that the plan document was created/updated
- do not mark the issue done
## Relationship to the Artifact Plan
This work should explicitly feed the broader artifact/deliverables direction.
Recommendation:
- keep documents as their own primitive in this change
- add `document` to any future `ArtifactKind`
- later build a deliverables read-model that aggregates:
- issue documents
- issue attachments
- preview URLs
- workspace-file references
The artifact proposal currently has no explicit `document` kind. It should.
Recommended future shape:
```ts
type ArtifactKind =
| "document"
| "attachment"
| "workspace_file"
| "preview"
| "report_link";
```
## Implementation Phases
## Phase 1: Shared contract and schema
Files:
- `packages/db/src/schema/documents.ts`
- `packages/db/src/schema/document_revisions.ts`
- `packages/db/src/schema/issue_documents.ts`
- `packages/db/src/schema/index.ts`
- `packages/db/src/migrations/*`
- `packages/shared/src/types/issue.ts`
- `packages/shared/src/validators/issue.ts` or new document validator file
- `packages/shared/src/index.ts`
Acceptance:
- schema enforces one key per issue
- revisions are append-only
- shared types expose plan/document fields on issue fetch
## Phase 2: Server services and routes
Files:
- `server/src/services/issues.ts` or `server/src/services/documents.ts`
- `server/src/routes/issues.ts`
- `server/src/services/activity.ts` callsites
Behavior:
- list/get/upsert/delete documents
- revision listing
- `GET /issues/:id` returns `planDocument` + `documentSummaries`
- company boundary checks match issue routes
Acceptance:
- agents and board can fetch/update same-company issue documents
- stale edits return `409`
- activity timeline shows document changes
## Phase 3: UI issue documents surface
Files:
- `ui/src/api/issues.ts`
- `ui/src/lib/queryKeys.ts`
- `ui/src/pages/IssueDetail.tsx`
- new reusable document UI component if needed
Behavior:
- render plan + documents under description
- create/update by key
- open revision history
- show conflicts/errors clearly
Acceptance:
- board can create a `plan` doc from issue detail
- updated plan appears immediately
- issue detail no longer depends on description-embedded `<plan>`
## Phase 4: Skills/docs migration
Files:
- `skills/paperclip/SKILL.md`
- `docs/api/issues.md`
- `doc/SPEC-implementation.md`
- relevant plan/docs that mention `<plan>`
Acceptance:
- planning guidance references issue documents, not inline issue description tags
- API docs describe the new document endpoints and issue payload additions
## Phase 5: Legacy compatibility and follow-up
Behavior:
- read legacy `<plan>` blocks as fallback
- optionally add explicit import/migration command later
Follow-up, not required for first merge:
- deliverables/artifact read-model
- project/company documents
- comment-linked documents
- diff view between revisions
## Test Plan
### Server
- document create/read/update/delete lifecycle
- revision numbering
- `baseRevisionId` conflict handling
- company boundary enforcement
- agent vs board authorization
- issue fetch includes `planDocument` and document summaries
- legacy `<plan>` fallback behavior
- activity log mutation coverage
### UI
- issue detail shows plan document
- create/update flows invalidate queries correctly
- conflict and validation errors are surfaced
- legacy plan fallback renders correctly
### Verification
Run before implementation is declared complete:
```sh
pnpm -r typecheck
pnpm test:run
pnpm build
```
## Open Questions
1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred?
Recommendation: allow all four in API, optimize UI for markdown only.
2. Should agents be allowed to create arbitrary keys, or only conventional keys?
Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only.
3. Should delete exist in v1?
Recommendation: yes, but board-only.
4. Should legacy `<plan>` blocks ever be auto-migrated?
Recommendation: no automatic mutation in the first rollout.
5. Should documents appear inside a future Deliverables section or remain a top-level Issue section?
Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added.
## Final Recommendation
Ship **issue documents** as a focused, text-first primitive now.
Do not try to solve full artifact unification in the same implementation.
Use:
- first-class document tables
- issue-level keyed linkage
- append-only revisions
- `planDocument` embedded in normal issue fetches
- legacy `<plan>` fallback
- skill/docs migration away from description-embedded plans
This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward.

View File

@@ -112,6 +112,16 @@ export function renderTemplate(template: string, data: Record<string, unknown>)
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
}
export function joinPromptSections(
sections: Array<string | null | undefined>,
separator = "\n\n",
) {
return sections
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
.join(separator);
}
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {

View File

@@ -99,6 +99,7 @@ export interface AdapterInvocationMeta {
commandNotes?: string[];
env?: Record<string, string>;
prompt?: string;
promptMetrics?: Record<string, number>;
context?: Record<string, unknown>;
}

View File

@@ -12,6 +12,7 @@ import {
parseObject,
parseJson,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -363,7 +364,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const prompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -371,7 +373,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildClaudeArgs = (resumeSessionId: string | null) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
@@ -416,6 +435,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandNotes,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -67,6 +67,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;

View File

@@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string {
const fromEnv = nonEmpty(env.CODEX_HOME);
if (fromEnv) return path.resolve(fromEnv);
return path.join(os.homedir(), ".codex");
}
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
}
function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null {
if (!isWorktreeMode(env)) return null;
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);
if (!paperclipHome) return null;
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);
if (instanceId) {
return path.resolve(paperclipHome, "instances", instanceId, "codex-home");
}
return path.resolve(paperclipHome, "codex-home");
}
async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
return;
}
if (!existing.isSymbolicLink()) {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
await fs.unlink(target);
await fs.symlink(source, target);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (existing) return;
await ensureParentDir(target);
await fs.copyFile(source, target);
}
export async function prepareWorktreeCodexHome(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
): Promise<string | null> {
const targetHome = resolveWorktreeCodexHomeDir(env);
if (!targetHome) return null;
const sourceHome = resolveCodexHomeDir(env);
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
await fs.mkdir(targetHome, { recursive: true });
for (const name of SYMLINKED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureSymlink(path.join(targetHome, name), source);
}
for (const name of COPIED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureCopiedFile(path.join(targetHome, name), source);
}
await onLog(
"stderr",
`[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
);
return targetHome;
}

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
@@ -18,9 +17,11 @@ import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const CODEX_ROLLOUT_NOISE_RE =
@@ -60,10 +61,32 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
pathExists(path.join(candidate, "package.json")),
pathExists(path.join(candidate, "server")),
pathExists(path.join(candidate, "packages", "adapter-utils")),
]);
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
}
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
if (path.basename(candidate) !== skillName) return false;
const skillsRoot = path.dirname(candidate);
if (path.basename(skillsRoot) !== "skills") return false;
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
let cursor = path.dirname(skillsRoot);
for (let depth = 0; depth < 6; depth += 1) {
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
return false;
}
type EnsureCodexSkillsInjectedOptions = {
@@ -79,7 +102,7 @@ export async function ensureCodexSkillsInjected(
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills");
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
@@ -96,6 +119,31 @@ export async function ensureCodexSkillsInjected(
const target = path.join(skillsHome, entry.name);
try {
const existing = await fs.lstat(target).catch(() => null);
if (existing?.isSymbolicLink()) {
const linkedPath = await fs.readlink(target).catch(() => null);
const resolvedLinkedPath = linkedPath
? path.resolve(path.dirname(target), linkedPath)
: null;
if (
resolvedLinkedPath &&
resolvedLinkedPath !== entry.source &&
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
) {
await fs.unlink(target);
if (linkSkill) {
await linkSkill(entry.source, target);
} else {
await fs.symlink(entry.source, target);
}
await onLog(
"stderr",
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
);
continue;
}
}
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
@@ -160,12 +208,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
await ensureCodexSkillsInjected(
onLog,
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
if (effectiveCodexHome) {
env.CODEX_HOME = effectiveCodexHome;
}
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@@ -278,6 +339,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -285,6 +347,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -309,7 +372,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -317,8 +381,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
@@ -346,6 +428,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -71,6 +71,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;

View File

@@ -17,6 +17,7 @@ import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
@@ -268,6 +269,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -275,6 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -307,7 +310,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -315,9 +319,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
@@ -340,6 +364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -62,6 +62,7 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;

View File

@@ -13,6 +13,7 @@ import {
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
@@ -268,7 +269,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -276,10 +278,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
@@ -309,6 +332,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -56,6 +56,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;

View File

@@ -9,6 +9,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -233,7 +234,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -241,8 +243,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
@@ -264,6 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)

View File

@@ -9,6 +9,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -270,7 +271,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
systemPromptExtension = promptTemplate;
}
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -278,18 +280,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
// User prompt is simple - just the rendered prompt template without instructions
const userPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
const renderedHeartbeatPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const userPrompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedHeartbeatPrompt,
]);
const promptMetrics = {
systemPromptChars: renderedSystemPromptExtension.length,
promptChars: userPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedHeartbeatPrompt.length,
};
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
@@ -345,6 +355,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt: userPrompt,
promptMetrics,
context,
});
}

View File

@@ -48,6 +48,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;

View File

@@ -0,0 +1,54 @@
CREATE TABLE "document_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"revision_number" integer NOT NULL,
"body" text NOT NULL,
"change_summary" text,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"title" text,
"format" text DEFAULT 'markdown' NOT NULL,
"latest_body" text NOT NULL,
"latest_revision_id" uuid,
"latest_revision_number" integer DEFAULT 1 NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"updated_by_agent_id" uuid,
"updated_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "issue_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint
CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint
CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint
CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint
CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at");

View File

@@ -0,0 +1,177 @@
-- Rollback:
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
-- DROP INDEX IF EXISTS "plugins_status_idx";
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
-- DROP TABLE IF EXISTS "plugin_logs";
-- DROP TABLE IF EXISTS "plugin_company_settings";
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
-- DROP TABLE IF EXISTS "plugin_job_runs";
-- DROP TABLE IF EXISTS "plugin_jobs";
-- DROP TABLE IF EXISTS "plugin_entities";
-- DROP TABLE IF EXISTS "plugin_state";
-- DROP TABLE IF EXISTS "plugin_config";
-- DROP TABLE IF EXISTS "plugins";
CREATE TABLE "plugins" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_key" text NOT NULL,
"package_name" text NOT NULL,
"package_path" text,
"version" text NOT NULL,
"api_version" integer DEFAULT 1 NOT NULL,
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
"manifest_json" jsonb NOT NULL,
"status" text DEFAULT 'installed' NOT NULL,
"install_order" integer,
"last_error" text,
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"scope_kind" text NOT NULL,
"scope_id" text,
"namespace" text DEFAULT 'default' NOT NULL,
"state_key" text NOT NULL,
"value_json" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
);
--> statement-breakpoint
CREATE TABLE "plugin_entities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"scope_kind" text NOT NULL,
"scope_id" text,
"external_id" text,
"title" text,
"status" text,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"job_key" text NOT NULL,
"schedule" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"last_run_at" timestamp with time zone,
"next_run_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_job_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"trigger" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"duration_ms" integer,
"error" text,
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"webhook_key" text NOT NULL,
"external_id" text,
"status" text DEFAULT 'pending' NOT NULL,
"duration_ms" integer,
"error" text,
"payload" jsonb NOT NULL,
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_company_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"plugin_id" uuid NOT NULL,
"level" text NOT NULL DEFAULT 'info',
"message" text NOT NULL,
"meta" jsonb,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,20 @@
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1773432085646,
"tag": "0028_harsh_goliath",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1773417600000,
"tag": "0029_plugin_tables",
"breakpoints": true
}
]
}
}

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { documents } from "./documents.js";
export const documentRevisions = pgTable(
"document_revisions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
body: text("body").notNull(),
changeSummary: text("change_summary"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on(
table.documentId,
table.revisionNumber,
),
companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on(
table.companyId,
table.documentId,
table.createdAt,
),
}),
);

View File

@@ -0,0 +1,26 @@
import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
title: text("title"),
format: text("format").notNull().default("markdown"),
latestBody: text("latest_body").notNull(),
latestRevisionId: uuid("latest_revision_id"),
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt),
}),
);

View File

@@ -24,6 +24,9 @@ export { issueComments } from "./issue_comments.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js";
export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { costEvents } from "./cost_events.js";
@@ -32,3 +35,11 @@ export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
export { pluginState } from "./plugin_state.js";
export { pluginEntities } from "./plugin_entities.js";
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
export { pluginLogs } from "./plugin_logs.js";

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { documents } from "./documents.js";
export const issueDocuments = pgTable(
"issue_documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
key: text("key").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on(
table.companyId,
table.issueId,
table.key,
),
documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId),
companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on(
table.companyId,
table.issueId,
table.updatedAt,
),
}),
);

View File

@@ -0,0 +1,41 @@
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
/**
* `plugin_company_settings` table — stores operator-managed plugin settings
* scoped to a specific company.
*
* This is distinct from `plugin_config`, which stores instance-wide plugin
* configuration. Each company can have at most one settings row per plugin.
*
* Rows represent explicit overrides from the default company behavior:
* - no row => plugin is enabled for the company by default
* - row with `enabled = false` => plugin is disabled for that company
* - row with `enabled = true` => plugin remains enabled and stores company settings
*/
export const pluginCompanySettings = pgTable(
"plugin_company_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
table.companyId,
table.pluginId,
),
}),
);

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_config` table — stores operator-provided instance configuration
* for each plugin (one row per plugin, enforced by a unique index on
* `plugin_id`).
*
* The `config_json` column holds the values that the operator enters in the
* plugin settings UI. These values are validated at runtime against the
* plugin's `instanceConfigSchema` from the manifest.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginConfig = pgTable(
"plugin_config",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
}),
);

View File

@@ -0,0 +1,54 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginStateScopeKind } from "@paperclipai/shared";
/**
* `plugin_entities` table — persistent high-level mapping between Paperclip
* objects and external plugin-defined entities.
*
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
* to their respective external IDs for projects, issues, etc. and to store
* their custom data.
*
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
* is intended for structured object mappings that the host can understand
* and query for cross-plugin UI integration.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginEntities = pgTable(
"plugin_entities",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
externalId: text("external_id"), // ID in the external system
title: text("title"),
status: text("status"),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
table.pluginId,
table.entityType,
table.externalId,
),
}),
);

View File

@@ -0,0 +1,102 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
/**
* `plugin_jobs` table — registration and runtime configuration for
* scheduled jobs declared by plugins in their manifests.
*
* Each row represents one scheduled job entry for a plugin. The
* `job_key` matches the key declared in the manifest's `jobs` array.
* The `schedule` column stores the cron expression or interval string
* used by the job scheduler to decide when to fire the job.
*
* Status values:
* - `active` — job is enabled and will run on schedule
* - `paused` — job is temporarily disabled by the operator
* - `error` — job has been disabled due to repeated failures
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
*/
export const pluginJobs = pgTable(
"plugin_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `jobs` array. */
jobKey: text("job_key").notNull(),
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
schedule: text("schedule").notNull(),
/** Current scheduling state. */
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
/** Timestamp of the most recent successful execution. */
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
/** Pre-computed timestamp of the next scheduled execution. */
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
}),
);
/**
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
*
* Each row is created when a job run begins and updated when it completes.
* Rows are never modified after `status` reaches a terminal value
* (`succeeded` | `failed` | `cancelled`).
*
* Trigger values:
* - `scheduled` — fired automatically by the cron/interval scheduler
* - `manual` — triggered by an operator via the admin UI or API
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
*/
export const pluginJobRuns = pgTable(
"plugin_job_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the parent job definition. Cascades on delete. */
jobId: uuid("job_id")
.notNull()
.references(() => pluginJobs.id, { onDelete: "cascade" }),
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** What caused this run to start (`"scheduled"` or `"manual"`). */
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
/** Current lifecycle state of this run. */
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
/** Wall-clock duration in milliseconds. Null until the run finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Ordered list of log lines emitted during this run. */
logs: jsonb("logs").$type<string[]>().notNull().default([]),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
}),
);

View File

@@ -0,0 +1,43 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_logs` table — structured log storage for plugin workers.
*
* Each row stores a single log entry emitted by a plugin worker via
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
* time range to support the operator logs panel and debugging workflows.
*
* Rows are inserted by the host when handling `log` notifications from
* the worker process. A capped retention policy can be applied via
* periodic cleanup (e.g. delete rows older than 7 days).
*
* @see PLUGIN_SPEC.md §26 — Observability
*/
export const pluginLogs = pgTable(
"plugin_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
level: text("level").notNull().default("info"),
message: text("message").notNull(),
meta: jsonb("meta").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
table.pluginId,
table.createdAt,
),
levelIdx: index("plugin_logs_level_idx").on(table.level),
}),
);

View File

@@ -0,0 +1,90 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
unique,
} from "drizzle-orm/pg-core";
import type { PluginStateScopeKind } from "@paperclipai/shared";
import { plugins } from "./plugins.js";
/**
* `plugin_state` table — scoped key-value storage for plugin workers.
*
* Each row stores a single JSON value identified by
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
* this table through `ctx.state.get()`, `ctx.state.set()`, and
* `ctx.state.delete()` in the SDK.
*
* Scope kinds determine the granularity of isolation:
* - `instance` — one value shared across the whole Paperclip instance
* - `company` — one value per company
* - `project` — one value per project
* - `project_workspace` — one value per project workspace
* - `agent` — one value per agent
* - `issue` — one value per issue
* - `goal` — one value per goal
* - `run` — one value per agent run
*
* The `namespace` column defaults to `"default"` and can be used to
* logically group keys without polluting the root namespace.
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
*/
export const pluginState = pgTable(
"plugin_state",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
/**
* UUID or text identifier for the scoped object.
* Null for `instance` scope (which has no associated entity).
*/
scopeId: text("scope_id"),
/**
* Sub-namespace to avoid key collisions within a scope.
* Defaults to `"default"` if the plugin does not specify one.
*/
namespace: text("namespace").notNull().default("default"),
/** The key identifying this state entry within the namespace. */
stateKey: text("state_key").notNull(),
/** JSON-serializable value stored by the plugin. */
valueJson: jsonb("value_json").notNull(),
/** Timestamp of the most recent write. */
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
/**
* Unique constraint enforces that there is at most one value per
* (plugin, scope kind, scope id, namespace, key) tuple.
*
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
* (used by `instance` scope) are treated as equal by PostgreSQL rather
* than as distinct nulls — otherwise the upsert target in `set()` would
* fail to match existing rows and create duplicates.
*
* Requires PostgreSQL 15+.
*/
uniqueEntry: unique("plugin_state_unique_entry_idx")
.on(
table.pluginId,
table.scopeKind,
table.scopeId,
table.namespace,
table.stateKey,
)
.nullsNotDistinct(),
/** Speed up lookups by plugin + scope kind (most common access pattern). */
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
table.pluginId,
table.scopeKind,
),
}),
);

View File

@@ -0,0 +1,65 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
/**
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
*
* When an external system sends an HTTP POST to a plugin's registered webhook
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
* creates a row in this table before dispatching the payload to the plugin
* worker. This provides an auditable log of every delivery attempt.
*
* The `webhook_key` matches the key declared in the plugin manifest's
* `webhooks` array. `external_id` is an optional identifier supplied by the
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
* and reject duplicate deliveries.
*
* Status values:
* - `pending` — received but not yet dispatched to the worker
* - `processing` — currently being handled by the plugin worker
* - `succeeded` — worker processed the payload successfully
* - `failed` — worker returned an error or timed out
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
*/
export const pluginWebhookDeliveries = pgTable(
"plugin_webhook_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
webhookKey: text("webhook_key").notNull(),
/** Optional de-duplication ID provided by the external system. */
externalId: text("external_id"),
/** Current delivery state. */
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Raw JSON body of the inbound HTTP request. */
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
}),
);

View File

@@ -0,0 +1,45 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* `plugins` table — stores one row per installed plugin.
*
* Each plugin is uniquely identified by `plugin_key` (derived from
* the manifest `id`). The full manifest is persisted as JSONB in
* `manifest_json` so the host can reconstruct capability and UI
* slot information without loading the plugin package.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const plugins = pgTable(
"plugins",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginKey: text("plugin_key").notNull(),
packageName: text("package_name").notNull(),
version: text("version").notNull(),
apiVersion: integer("api_version").notNull().default(1),
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
status: text("status").$type<PluginStatus>().notNull().default("installed"),
installOrder: integer("install_order"),
/** Resolved package path for local-path installs; used to find worker entrypoint. */
packagePath: text("package_path"),
lastError: text("last_error"),
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
statusIdx: index("plugins_status_idx").on(table.status),
}),
);

View File

@@ -0,0 +1,52 @@
# @paperclipai/create-paperclip-plugin
Scaffolding tool for creating new Paperclip plugins.
```bash
npx @paperclipai/create-paperclip-plugin my-plugin
```
Or with options:
```bash
npx @paperclipai/create-paperclip-plugin @acme/my-plugin \
--template connector \
--category connector \
--display-name "Acme Connector" \
--description "Syncs Acme data into Paperclip" \
--author "Acme Inc"
```
Supported templates: `default`, `connector`, `workspace`
Supported categories: `connector`, `workspace`, `automation`, `ui`
Generates:
- typed manifest + worker entrypoint
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
- test file using `@paperclipai/plugin-sdk/testing`
- `esbuild` and `rollup` config files using SDK bundler presets
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
```bash
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
--output /absolute/path/to/plugins \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
That gives you an outside-repo local development path before the SDK is published to npm.
## Workflow after scaffolding
```bash
cd my-plugin
pnpm install
pnpm dev # watch worker + manifest + ui bundles
pnpm dev:ui # local UI preview server with hot-reload events
pnpm test
```

View File

@@ -0,0 +1,40 @@
{
"name": "@paperclipai/create-paperclip-plugin",
"version": "0.1.0",
"type": "module",
"bin": {
"create-paperclip-plugin": "./dist/index.js"
},
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"bin": {
"create-paperclip-plugin": "./dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,496 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
export interface ScaffoldPluginOptions {
pluginName: string;
outputDir: string;
template?: PluginTemplate;
displayName?: string;
description?: string;
author?: string;
category?: "connector" | "workspace" | "automation" | "ui";
sdkPath?: string;
}
/** Validate npm-style plugin package names (scoped or unscoped). */
export function isValidPluginName(name: string): boolean {
const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
const unscopedPattern = /^[a-z0-9._-]+$/;
return scopedPattern.test(name) || unscopedPattern.test(name);
}
/** Convert `@scope/name` to an output directory basename (`name`). */
function packageToDirName(pluginName: string): string {
return pluginName.replace(/^@[^/]+\//, "");
}
/** Convert an npm package name into a manifest-safe plugin id. */
function packageToManifestId(pluginName: string): string {
if (!pluginName.startsWith("@")) {
return pluginName;
}
return pluginName.slice(1).replace("/", ".");
}
/** Build a human-readable display name from package name tokens. */
function makeDisplayName(pluginName: string): string {
const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
return raw
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function writeFile(target: string, content: string) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
function quote(value: string): string {
return JSON.stringify(value);
}
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function formatFileDependency(absPath: string): string {
return `file:${toPosixPath(path.resolve(absPath))}`;
}
function getLocalSdkPackagePath(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
}
function getRepoRootFromSdkPath(sdkPath: string): string {
return path.resolve(sdkPath, "..", "..", "..");
}
function getLocalSharedPackagePath(sdkPath: string): string {
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
}
function isInsideDir(targetPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, targetPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function packLocalPackage(packagePath: string, outputDir: string): string {
const packageJsonPath = path.join(packagePath, "package.json");
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`Package package.json not found at ${packageJsonPath}`);
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
name?: string;
version?: string;
};
const packageName = packageJson.name ?? path.basename(packagePath);
const packageVersion = packageJson.version ?? "0.0.0";
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
fs.mkdirSync(sdkBundleDir, { recursive: true });
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
const tarballPath = path.join(sdkBundleDir, tarballFileName);
if (!fs.existsSync(tarballPath)) {
throw new Error(`Packed tarball was not created at ${tarballPath}`);
}
return tarballPath;
}
/**
* Generate a complete Paperclip plugin starter project.
*
* Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
* and a local dev server script for hot-reload workflow.
*/
export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
const template = options.template ?? "default";
if (!VALID_TEMPLATES.includes(template)) {
throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
}
if (!isValidPluginName(options.pluginName)) {
throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
}
if (options.category && !VALID_CATEGORIES.has(options.category)) {
throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
}
const outputDir = path.resolve(options.outputDir);
if (fs.existsSync(outputDir)) {
throw new Error(`Directory already exists: ${outputDir}`);
}
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
const description = options.description ?? "A Paperclip plugin";
const author = options.author ?? "Plugin Author";
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
const manifestId = packageToManifestId(options.pluginName);
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
fs.mkdirSync(outputDir, { recursive: true });
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
const sdkDependency = useWorkspaceSdk
? "workspace:*"
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
const packageJson = {
name: options.pluginName,
version: "0.1.0",
type: "module",
private: true,
description,
scripts: {
build: "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
dev: "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
test: "vitest run --config ./vitest.config.ts",
typecheck: "tsc --noEmit"
},
paperclipPlugin: {
manifest: "./dist/manifest.js",
worker: "./dist/worker.js",
ui: "./dist/ui/"
},
keywords: ["paperclip", "plugin", category],
author,
license: "MIT",
...(packedSharedTarball
? {
pnpm: {
overrides: {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
},
},
}
: {}),
devDependencies: {
...(packedSharedTarball
? {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
}
: {}),
"@paperclipai/plugin-sdk": sdkDependency,
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
esbuild: "^0.27.3",
rollup: "^4.38.0",
tslib: "^2.8.1",
typescript: "^5.7.3",
vitest: "^3.0.5"
},
peerDependencies: {
react: ">=18"
}
};
writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
const tsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2022", "DOM"],
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
declaration: true,
declarationMap: true,
sourceMap: true,
outDir: "dist",
rootDir: "."
},
include: ["src", "tests"],
exclude: ["dist", "node_modules"]
};
writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
writeFile(
path.join(outputDir, "esbuild.config.mjs"),
`import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}
`,
);
writeFile(
path.join(outputDir, "rollup.config.mjs"),
`import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);
`,
);
writeFile(
path.join(outputDir, "vitest.config.ts"),
`import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
`,
);
writeFile(
path.join(outputDir, "src", "manifest.ts"),
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: ${quote(manifestId)},
apiVersion: 1,
version: "0.1.0",
displayName: ${quote(displayName)},
description: ${quote(description)},
author: ${quote(author)},
categories: [${quote(category)}],
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write"
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "health-widget",
displayName: ${quote(`${displayName} Health`)},
exportName: "DashboardWidget"
}
]
}
};
export default manifest;
`,
);
writeFile(
path.join(outputDir, "src", "worker.ts"),
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
const plugin = definePlugin({
async setup(ctx) {
ctx.events.on("issue.created", async (event) => {
const issueId = event.entityId ?? "unknown";
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
ctx.logger.info("Observed issue.created", { issueId });
});
ctx.data.register("health", async () => {
return { status: "ok", checkedAt: new Date().toISOString() };
});
ctx.actions.register("ping", async () => {
ctx.logger.info("Ping action invoked");
return { pong: true, at: new Date().toISOString() };
});
},
async onHealth() {
return { status: "ok", message: "Plugin worker is running" };
}
});
export default plugin;
runWorker(plugin, import.meta.url);
`,
);
writeFile(
path.join(outputDir, "src", "ui", "index.tsx"),
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
type HealthData = {
status: "ok" | "degraded" | "error";
checkedAt: string;
};
export function DashboardWidget(_props: PluginWidgetProps) {
const { data, loading, error } = usePluginData<HealthData>("health");
const ping = usePluginAction("ping");
if (loading) return <div>Loading plugin health...</div>;
if (error) return <div>Plugin error: {error.message}</div>;
return (
<div style={{ display: "grid", gap: "0.5rem" }}>
<strong>${displayName}</strong>
<div>Health: {data?.status ?? "unknown"}</div>
<div>Checked: {data?.checkedAt ?? "never"}</div>
<button onClick={() => void ping()}>Ping Worker</button>
</div>
);
}
`,
);
writeFile(
path.join(outputDir, "tests", "plugin.spec.ts"),
`import { describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
describe("plugin scaffold", () => {
it("registers data + actions and handles events", async () => {
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
await plugin.definition.setup(harness.ctx);
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
const data = await harness.getData<{ status: string }>("health");
expect(data.status).toBe("ok");
const action = await harness.performAction<{ pong: boolean }>("ping");
expect(action.pong).toBe(true);
});
});
`,
);
writeFile(
path.join(outputDir, "README.md"),
`# ${displayName}
${description}
## Development
\`\`\`bash
pnpm install
pnpm dev # watch builds
pnpm dev:ui # local dev server with hot-reload events
pnpm test
\`\`\`
${sdkDependency.startsWith("file:")
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
: ""}
## Install Into Paperclip
\`\`\`bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
-H "Content-Type: application/json" \\
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
\`\`\`
## Build Options
- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`.
- \`pnpm build:rollup\` uses rollup presets from the same SDK.
`,
);
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
return outputDir;
}
function parseArg(name: string): string | undefined {
const index = process.argv.indexOf(name);
if (index === -1) return undefined;
return process.argv[index + 1];
}
/** CLI wrapper for `scaffoldPluginProject`. */
function runCli() {
const pluginName = process.argv[2];
if (!pluginName) {
// eslint-disable-next-line no-console
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
process.exit(1);
}
const template = (parseArg("--template") ?? "default") as PluginTemplate;
const outputRoot = parseArg("--output") ?? process.cwd();
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
const out = scaffoldPluginProject({
pluginName,
outputDir: targetDir,
template,
displayName: parseArg("--display-name"),
description: parseArg("--description"),
author: parseArg("--author"),
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
sdkPath: parseArg("--sdk-path"),
});
// eslint-disable-next-line no-console
console.log(`Created plugin scaffold at ${out}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
runCli();
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,2 @@
dist
node_modules

View File

@@ -0,0 +1,23 @@
# Plugin Authoring Smoke Example
A Paperclip plugin
## Development
```bash
pnpm install
pnpm dev # watch builds
pnpm dev:ui # local dev server with hot-reload events
pnpm test
```
## Install Into Paperclip
```bash
pnpm paperclipai plugin install ./
```
## Build Options
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
- `pnpm build:rollup` uses rollup presets from the same SDK.

View File

@@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}

View File

@@ -0,0 +1,45 @@
{
"name": "@paperclipai/plugin-authoring-smoke-example",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "A Paperclip plugin",
"scripts": {
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"connector"
],
"author": "Plugin Author",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"esbuild": "^0.27.3",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}

View File

@@ -0,0 +1,28 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);

View File

@@ -0,0 +1,32 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "paperclipai.plugin-authoring-smoke-example",
apiVersion: 1,
version: "0.1.0",
displayName: "Plugin Authoring Smoke Example",
description: "A Paperclip plugin",
author: "Plugin Author",
categories: ["connector"],
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write"
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "health-widget",
displayName: "Plugin Authoring Smoke Example Health",
exportName: "DashboardWidget"
}
]
}
};
export default manifest;

View File

@@ -0,0 +1,23 @@
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
type HealthData = {
status: "ok" | "degraded" | "error";
checkedAt: string;
};
export function DashboardWidget(_props: PluginWidgetProps) {
const { data, loading, error } = usePluginData<HealthData>("health");
const ping = usePluginAction("ping");
if (loading) return <div>Loading plugin health...</div>;
if (error) return <div>Plugin error: {error.message}</div>;
return (
<div style={{ display: "grid", gap: "0.5rem" }}>
<strong>Plugin Authoring Smoke Example</strong>
<div>Health: {data?.status ?? "unknown"}</div>
<div>Checked: {data?.checkedAt ?? "never"}</div>
<button onClick={() => void ping()}>Ping Worker</button>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
const plugin = definePlugin({
async setup(ctx) {
ctx.events.on("issue.created", async (event) => {
const issueId = event.entityId ?? "unknown";
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
ctx.logger.info("Observed issue.created", { issueId });
});
ctx.data.register("health", async () => {
return { status: "ok", checkedAt: new Date().toISOString() };
});
ctx.actions.register("ping", async () => {
ctx.logger.info("Ping action invoked");
return { pong: true, at: new Date().toISOString() };
});
},
async onHealth() {
return { status: "ok", message: "Plugin worker is running" };
}
});
export default plugin;
runWorker(plugin, import.meta.url);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
describe("plugin scaffold", () => {
it("registers data + actions and handles events", async () => {
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
await plugin.definition.setup(harness.ctx);
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
const data = await harness.getData<{ status: string }>("health");
expect(data.status).toBe("ok");
const action = await harness.performAction<{ pong: boolean }>("ping");
expect(action.pong).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});

View File

@@ -0,0 +1,62 @@
# File Browser Example Plugin
Example Paperclip plugin that demonstrates:
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugins tab selected. This is controlled by plugin settings and defaults to off.
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
## Slots
| Slot | Type | Description |
|---------------------|---------------------|--------------------------------------------------|
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
## Settings
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
## Capabilities
- `ui.sidebar.register` — project sidebar item
- `ui.detailTab.register` — project detail tab
- `projects.read` — resolve project
- `project.workspaces.read` — list workspaces and read paths for file access
## Worker
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
## Local Install (Dev)
From the repo root, build the plugin and install it by local path:
```bash
pnpm --filter @paperclipai/plugin-file-browser-example build
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
```
To uninstall:
```bash
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
```
**Local development notes:**
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
## Structure
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
- `src/worker.ts` — data handlers for workspaces, file list, file content.
- `src/ui/index.tsx``FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).

View File

@@ -0,0 +1,42 @@
{
"name": "@paperclipai/plugin-file-browser-example",
"version": "0.1.0",
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"scripts": {
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
"build": "tsc && node ./scripts/build-ui.mjs",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.1",
"@paperclipai/plugin-sdk": "workspace:*",
"codemirror": "^6.0.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"esbuild": "^0.27.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": ">=18"
}
}

View File

@@ -0,0 +1,24 @@
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, "..");
await esbuild.build({
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
outfile: path.join(packageRoot, "dist/ui/index.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@paperclipai/plugin-sdk/ui",
],
logLevel: "info",
});

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,85 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip-file-browser-example";
const FILES_SIDEBAR_SLOT_ID = "files-link";
const FILES_TAB_SLOT_ID = "files-tab";
const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.2.0",
displayName: "File Browser (Example)",
description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"ui.sidebar.register",
"ui.detailTab.register",
"ui.commentAnnotation.register",
"ui.action.register",
"projects.read",
"project.workspaces.read",
"issue.comments.read",
"plugin.state.read",
],
instanceConfigSchema: {
type: "object",
properties: {
showFilesInSidebar: {
type: "boolean",
title: "Show Files in Sidebar",
default: false,
description: "Adds the Files link under each project in the sidebar.",
},
commentAnnotationMode: {
type: "string",
title: "Comment File Links",
enum: ["annotation", "contextMenu", "both", "none"],
default: "both",
description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
},
},
},
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
ui: {
slots: [
{
type: "projectSidebarItem",
id: FILES_SIDEBAR_SLOT_ID,
displayName: "Files",
exportName: "FilesLink",
entityTypes: ["project"],
order: 10,
},
{
type: "detailTab",
id: FILES_TAB_SLOT_ID,
displayName: "Files",
exportName: "FilesTab",
entityTypes: ["project"],
order: 10,
},
{
type: "commentAnnotation",
id: COMMENT_FILE_LINKS_SLOT_ID,
displayName: "File Links",
exportName: "CommentFileLinks",
entityTypes: ["comment"],
},
{
type: "commentContextMenuItem",
id: COMMENT_OPEN_FILES_SLOT_ID,
displayName: "Open in Files",
exportName: "CommentOpenFiles",
entityTypes: ["comment"],
},
],
},
};
export default manifest;

View File

@@ -0,0 +1,815 @@
import type {
PluginProjectSidebarItemProps,
PluginDetailTabProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,
} from "@paperclipai/plugin-sdk/ui";
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const PLUGIN_KEY = "paperclip-file-browser-example";
const FILES_TAB_SLOT_ID = "files-tab";
const editorBaseTheme = {
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
fontFamily:
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
fontSize: "13px",
lineHeight: "1.6",
},
".cm-content": {
padding: "12px 14px 18px",
},
};
const editorDarkTheme = EditorView.theme({
...editorBaseTheme,
"&": {
...editorBaseTheme["&"],
backgroundColor: "oklch(0.23 0.02 255)",
color: "oklch(0.93 0.01 255)",
},
".cm-gutters": {
backgroundColor: "oklch(0.25 0.015 255)",
color: "oklch(0.74 0.015 255)",
borderRight: "1px solid oklch(0.34 0.01 255)",
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
},
".cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
},
"&.cm-focused .cm-selectionBackground": {
backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "oklch(0.93 0.01 255)",
},
".cm-matchingBracket": {
backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
color: "oklch(0.95 0.01 255)",
outline: "none",
},
".cm-nonmatchingBracket": {
color: "oklch(0.70 0.08 24)",
},
}, { dark: true });
const editorLightTheme = EditorView.theme({
...editorBaseTheme,
"&": {
...editorBaseTheme["&"],
backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
color: "var(--foreground)",
},
".cm-content": {
...editorBaseTheme[".cm-content"],
caretColor: "var(--foreground)",
},
".cm-gutters": {
backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
color: "var(--muted-foreground)",
borderRight: "1px solid var(--border)",
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
},
".cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
},
"&.cm-focused .cm-selectionBackground": {
backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
},
".cm-matchingBracket": {
backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
color: "var(--foreground)",
outline: "none",
},
".cm-nonmatchingBracket": {
color: "var(--destructive)",
},
});
const editorDarkHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
{ tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
{ tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
]);
const editorLightHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
{ tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
{ tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
]);
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
type FileEntry = { name: string; path: string; isDirectory: boolean };
type FileTreeNodeProps = {
entry: FileEntry;
companyId: string | null;
projectId: string;
workspaceId: string;
selectedPath: string | null;
onSelect: (path: string) => void;
depth?: number;
};
const PathLikePattern = /[\\/]/;
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function isLikelyPath(pathValue: string): boolean {
const trimmed = pathValue.trim();
return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
}
function workspaceLabel(workspace: Workspace): string {
const pathLabel = workspace.path.trim();
const nameLabel = workspace.name.trim();
const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
if (!baseLabel) {
return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
}
return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
}
function useIsMobile(breakpointPx = 768): boolean {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
);
useEffect(() => {
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
const update = () => setIsMobile(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, [breakpointPx]);
return isMobile;
}
function useIsDarkMode(): boolean {
const [isDarkMode, setIsDarkMode] = useState(() =>
typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const update = () => setIsDarkMode(root.classList.contains("dark"));
update();
const observer = new MutationObserver(update);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDarkMode;
}
function useAvailableHeight(
ref: RefObject<HTMLElement | null>,
options?: { bottomPadding?: number; minHeight?: number },
): number | null {
const bottomPadding = options?.bottomPadding ?? 24;
const minHeight = options?.minHeight ?? 384;
const [height, setHeight] = useState<number | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
const update = () => {
const element = ref.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
setHeight(nextHeight);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
const observer = typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => update())
: null;
if (observer && ref.current) observer.observe(ref.current);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
observer?.disconnect();
};
}, [bottomPadding, minHeight, ref]);
return height;
}
function FileTreeNode({
entry,
companyId,
projectId,
workspaceId,
selectedPath,
onSelect,
depth = 0,
}: FileTreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isSelected = selectedPath === entry.path;
if (entry.isDirectory) {
return (
<li>
<button
type="button"
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
style={{ paddingLeft: `${depth * 14 + 8}px` }}
onClick={() => setIsExpanded((value) => !value)}
aria-expanded={isExpanded}
>
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
<span className="truncate font-medium">{entry.name}</span>
</button>
{isExpanded ? (
<ExpandedDirectoryChildren
directoryPath={entry.path}
companyId={companyId}
projectId={projectId}
workspaceId={workspaceId}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth}
/>
) : null}
</li>
);
}
return (
<li>
<button
type="button"
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
style={{ paddingLeft: `${depth * 14 + 23}px` }}
onClick={() => onSelect(entry.path)}
>
<span className="truncate">{entry.name}</span>
</button>
</li>
);
}
function ExpandedDirectoryChildren({
directoryPath,
companyId,
projectId,
workspaceId,
selectedPath,
onSelect,
depth,
}: {
directoryPath: string;
companyId: string | null;
projectId: string;
workspaceId: string;
selectedPath: string | null;
onSelect: (path: string) => void;
depth: number;
}) {
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
companyId,
projectId,
workspaceId,
directoryPath,
});
const children = childData?.entries ?? [];
if (children.length === 0) {
return null;
}
return (
<ul className="space-y-0.5">
{children.map((child) => (
<FileTreeNode
key={child.path}
entry={child}
companyId={companyId}
projectId={projectId}
workspaceId={workspaceId}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</ul>
);
}
/**
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
*/
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
const { data: config, loading: configLoading } = usePluginData<PluginConfig>("plugin-config", {});
const showFilesInSidebar = config?.showFilesInSidebar ?? false;
if (configLoading || !showFilesInSidebar) {
return null;
}
const projectId = context.entityId;
const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
.projectRef
?? projectId;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
const isActive = typeof window !== "undefined" && (() => {
const pathname = window.location.pathname.replace(/\/+$/, "");
const segments = pathname.split("/").filter(Boolean);
const projectsIndex = segments.indexOf("projects");
const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
const activeTab = new URLSearchParams(window.location.search).get("tab");
if (activeTab !== tabValue) return false;
if (!activeProjectRef) return false;
return activeProjectRef === projectId || activeProjectRef === projectRef;
})();
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (
event.defaultPrevented
|| event.button !== 0
|| event.metaKey
|| event.ctrlKey
|| event.altKey
|| event.shiftKey
) {
return;
}
event.preventDefault();
window.history.pushState({}, "", href);
window.dispatchEvent(new PopStateEvent("popstate"));
};
return (
<a
href={href}
onClick={handleClick}
aria-current={isActive ? "page" : undefined}
className={`block px-3 py-1 text-[12px] truncate transition-colors ${
isActive
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
}`}
>
Files
</a>
);
}
/**
* Project detail tab: workspace selector, file tree, and CodeMirror editor.
*/
export function FilesTab({ context }: PluginDetailTabProps) {
const companyId = context.companyId;
const projectId = context.entityId;
const isMobile = useIsMobile();
const isDarkMode = useIsDarkMode();
const panesRef = useRef<HTMLDivElement | null>(null);
const availableHeight = useAvailableHeight(panesRef, {
bottomPadding: isMobile ? 16 : 24,
minHeight: isMobile ? 320 : 420,
});
const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", {
projectId,
companyId,
});
const workspaces = workspacesData ?? [];
const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
const selectedWorkspace = useMemo(
() => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
[workspaces, resolvedWorkspaceId],
);
const fileListParams = useMemo(
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
[companyId, projectId, selectedWorkspace],
);
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
"fileList",
fileListParams,
);
const entries = fileListData?.entries ?? [];
// Track the `?file=` query parameter across navigations (popstate).
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("file") || null;
});
const lastConsumedFileRef = useRef<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
const onNav = () => {
const next = new URLSearchParams(window.location.search).get("file") || null;
setUrlFilePath(next);
};
window.addEventListener("popstate", onNav);
return () => window.removeEventListener("popstate", onNav);
}, []);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
setSelectedPath(null);
setMobileView("browser");
lastConsumedFileRef.current = null;
}, [selectedWorkspace?.id]);
// When a file path appears (or changes) in the URL and workspace is ready, select it.
useEffect(() => {
if (!urlFilePath || !selectedWorkspace) return;
if (lastConsumedFileRef.current === urlFilePath) return;
lastConsumedFileRef.current = urlFilePath;
setSelectedPath(urlFilePath);
setMobileView("editor");
}, [urlFilePath, selectedWorkspace]);
const fileContentParams = useMemo(
() =>
selectedPath && selectedWorkspace
? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
: null,
[companyId, projectId, selectedWorkspace, selectedPath],
);
const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
"fileContent",
fileContentParams ?? {},
);
const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
const writeFile = usePluginAction("writeFile");
const editorRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const loadedContentRef = useRef("");
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
useEffect(() => {
if (!editorRef.current) return;
const content = fileContentData?.content ?? "";
loadedContentRef.current = content;
setIsDirty(false);
setSaveMessage(null);
setSaveError(null);
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
const view = new EditorView({
doc: content,
extensions: [
basicSetup,
javascript(),
isDarkMode ? editorDarkTheme : editorLightTheme,
syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const nextValue = update.state.doc.toString();
setIsDirty(nextValue !== loadedContentRef.current);
setSaveMessage(null);
setSaveError(null);
}),
],
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [fileContentData?.content, selectedPath, isDarkMode]);
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
return;
}
if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
return;
}
event.preventDefault();
void handleSave();
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [selectedWorkspace, selectedPath, isDirty, isSaving]);
async function handleSave() {
if (!selectedWorkspace || !selectedPath || !viewRef.current) {
return;
}
const content = viewRef.current.state.doc.toString();
setIsSaving(true);
setSaveError(null);
setSaveMessage(null);
try {
await writeFile({
projectId,
companyId,
workspaceId: selectedWorkspace.id,
filePath: selectedPath,
content,
});
loadedContentRef.current = content;
setIsDirty(false);
setSaveMessage("Saved");
refreshFileContent();
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
} finally {
setIsSaving(false);
}
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4">
<label className="text-sm font-medium text-muted-foreground">Workspace</label>
<select
key={workspaceSelectKey}
className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={resolvedWorkspaceId ?? ""}
onChange={(e) => setWorkspaceId(e.target.value || null)}
>
{workspaces.map((w) => {
const label = workspaceLabel(w);
return (
<option key={`${w.id}:${label}`} value={w.id} label={label} title={label}>
{label}
</option>
);
})}
</select>
</div>
<div
ref={panesRef}
className="min-h-0"
style={{
display: isMobile ? "block" : "grid",
gap: "1rem",
gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)",
height: availableHeight ? `${availableHeight}px` : undefined,
minHeight: isMobile ? "20rem" : "26rem",
}}
>
<div
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }}
>
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
File Tree
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{selectedWorkspace ? (
fileListLoading ? (
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
) : entries.length > 0 ? (
<ul className="space-y-0.5">
{entries.map((entry) => (
<FileTreeNode
key={entry.path}
entry={entry}
companyId={companyId}
projectId={projectId}
workspaceId={selectedWorkspace.id}
selectedPath={selectedPath}
onSelect={(path) => {
setSelectedPath(path);
setMobileView("editor");
}}
/>
))}
</ul>
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
)
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
)}
</div>
</div>
<div
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }}
>
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
<div className="min-w-0">
<button
type="button"
className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground"
style={{ display: isMobile ? "inline-flex" : "none" }}
onClick={() => setMobileView("browser")}
>
Back to files
</button>
<div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div>
<div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
{isDirty || saveMessage || saveError ? (
<div className="border-b border-border px-4 py-2 text-xs">
{saveError ? (
<span className="text-destructive">{saveError}</span>
) : saveMessage ? (
<span className="text-emerald-600">{saveMessage}</span>
) : (
<span className="text-muted-foreground">Unsaved changes</span>
)}
</div>
) : null}
{selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
<div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div>
) : null}
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Comment Annotation: renders detected file links below each comment
// ---------------------------------------------------------------------------
type PluginConfig = {
showFilesInSidebar?: boolean;
commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
};
/**
* Per-comment annotation showing file-path-like links extracted from the
* comment body. Each link navigates to the project Files tab with the
* matching path pre-selected.
*
* Respects the `commentAnnotationMode` instance config — hidden when mode
* is `"contextMenu"` or `"none"`.
*/
function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
if (!projectId) return "#";
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
}
function navigateToFileBrowser(href: string, event: MouseEvent<HTMLAnchorElement>) {
if (
event.defaultPrevented
|| event.button !== 0
|| event.metaKey
|| event.ctrlKey
|| event.altKey
|| event.shiftKey
) {
return;
}
event.preventDefault();
window.history.pushState({}, "", href);
window.dispatchEvent(new PopStateEvent("popstate"));
}
export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
const mode = config?.commentAnnotationMode ?? "both";
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
commentId: context.entityId,
issueId: context.parentEntityId,
companyId: context.companyId,
});
if (mode === "contextMenu" || mode === "none") return null;
if (!data?.links?.length) return null;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const projectId = context.projectId;
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span>
{data.links.map((link) => {
const href = buildFileBrowserHref(prefix, projectId, link);
return (
<a
key={link}
href={href}
onClick={(e) => navigateToFileBrowser(href, e)}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors"
title={`Open ${link} in file browser`}
>
{link}
</a>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Comment Context Menu Item: "Open in Files" action per comment
// ---------------------------------------------------------------------------
/**
* Per-comment context menu item that appears in the comment "more" (⋮) menu.
* Extracts file paths from the comment body and, if any are found, renders
* a button to open the first file in the project Files tab.
*
* Respects the `commentAnnotationMode` instance config — hidden when mode
* is `"annotation"` or `"none"`.
*/
export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
const mode = config?.commentAnnotationMode ?? "both";
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
commentId: context.entityId,
issueId: context.parentEntityId,
companyId: context.companyId,
});
if (mode === "annotation" || mode === "none") return null;
if (!data?.links?.length) return null;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const projectId = context.projectId;
return (
<div>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Files
</div>
{data.links.map((link) => {
const href = buildFileBrowserHref(prefix, projectId, link);
const fileName = link.split("/").pop() ?? link;
return (
<a
key={link}
href={href}
onClick={(e) => navigateToFileBrowser(href, e)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors"
title={`Open ${link} in file browser`}
>
<span className="truncate font-mono">{fileName}</span>
</a>
);
})}
</div>
);
}

View File

@@ -0,0 +1,226 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
const PLUGIN_NAME = "file-browser-example";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PATH_LIKE_PATTERN = /[\\/]/;
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
function looksLikePath(value: string): boolean {
const normalized = value.trim();
return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
&& !UUID_PATTERN.test(normalized);
}
function sanitizeWorkspacePath(pathValue: string): string {
return looksLikePath(pathValue) ? pathValue.trim() : "";
}
function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
const root = path.resolve(workspacePath);
const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return resolved;
}
/**
* Regex that matches file-path-like tokens in comment text.
* Captures tokens that either start with `.` `/` `~` or contain a `/`
* (directory separator), plus bare words that could be filenames with
* extensions (e.g. `README.md`). The file-extension check in
* `extractFilePaths` filters out non-file matches.
*/
const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
/** Common file extensions to recognise path-like tokens as actual file references. */
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
/**
* Tokens that look like paths but are almost certainly URL route segments
* (e.g. `/projects/abc`, `/settings`, `/dashboard`).
*/
const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
function extractFilePaths(body: string): string[] {
const paths = new Set<string>();
for (const match of body.matchAll(FILE_PATH_REGEX)) {
const raw = match[1];
// Strip trailing punctuation that isn't part of a path
const cleaned = raw.replace(/[.:,;!?)]+$/, "");
if (cleaned.length <= 1) continue;
// Must have a file extension (e.g. .ts, .json, .md)
if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
// Skip things that look like URL routes
if (URL_ROUTE_PATTERN.test(cleaned)) continue;
paths.add(cleaned);
}
return [...paths];
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
// Expose the current plugin config so UI components can read operator
// settings from the canonical instance config store.
ctx.data.register("plugin-config", async () => {
const config = await ctx.config.get();
return {
showFilesInSidebar: config?.showFilesInSidebar === true,
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
};
});
// Fetch a comment by ID and extract file-path-like tokens from its body.
ctx.data.register("comment-file-links", async (params: Record<string, unknown>) => {
const commentId = typeof params.commentId === "string" ? params.commentId : "";
const issueId = typeof params.issueId === "string" ? params.issueId : "";
const companyId = typeof params.companyId === "string" ? params.companyId : "";
if (!commentId || !issueId || !companyId) return { links: [] };
try {
const comments = await ctx.issues.listComments(issueId, companyId);
const comment = comments.find((c) => c.id === commentId);
if (!comment?.body) return { links: [] };
return { links: extractFilePaths(comment.body) };
} catch (err) {
ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
return { links: [] };
}
});
ctx.data.register("workspaces", async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
if (!projectId || !companyId) return [];
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
return workspaces.map((w) => ({
id: w.id,
projectId: w.projectId,
name: w.name,
path: sanitizeWorkspacePath(w.path),
isPrimary: w.isPrimary,
}));
});
ctx.data.register(
"fileList",
async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
if (!projectId || !companyId || !workspaceId) return { entries: [] };
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { entries: [] };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { entries: [] };
const dirPath = resolveWorkspace(workspacePath, directoryPath);
if (!dirPath) {
return { entries: [] };
}
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return { entries: [] };
}
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
const entries = names.map((name) => {
const full = path.join(dirPath, name);
const stat = fs.lstatSync(full);
const relativePath = path.relative(workspacePath, full);
return {
name,
path: relativePath,
isDirectory: stat.isDirectory(),
};
}).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
},
);
ctx.data.register(
"fileContent",
async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const filePath = params.filePath as string;
if (!projectId || !companyId || !workspaceId || !filePath) {
return { content: null, error: "Missing file context" };
}
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { content: null, error: "Workspace not found" };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { content: null, error: "Workspace has no path" };
const fullPath = resolveWorkspace(workspacePath, filePath);
if (!fullPath) {
return { content: null, error: "Path outside workspace" };
}
try {
const content = fs.readFileSync(fullPath, "utf-8");
return { content };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: null, error: message };
}
},
);
ctx.actions.register(
"writeFile",
async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
if (!filePath) {
throw new Error("filePath must be a non-empty string");
}
const content = typeof params.content === "string" ? params.content : null;
if (!projectId || !companyId || !workspaceId) {
throw new Error("Missing workspace context");
}
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) {
throw new Error("Workspace not found");
}
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) {
throw new Error("Workspace has no path");
}
if (content === null) {
throw new Error("Missing file content");
}
const fullPath = resolveWorkspace(workspacePath, filePath);
if (!fullPath) {
throw new Error("Path outside workspace");
}
const stat = fs.statSync(fullPath);
if (!stat.isFile()) {
throw new Error("Selected path is not a file");
}
fs.writeFileSync(fullPath, content, "utf-8");
return {
ok: true,
path: filePath,
bytes: Buffer.byteLength(content, "utf-8"),
};
},
);
},
async onHealth() {
return { status: "ok", message: `${PLUGIN_NAME} ready` };
},
});
export default plugin;
runWorker(plugin, import.meta.url);

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -0,0 +1,38 @@
# @paperclipai/plugin-hello-world-example
First-party reference plugin showing the smallest possible UI extension.
## What It Demonstrates
- a manifest with a `dashboardWidget` UI slot
- `entrypoints.ui` wiring for plugin UI bundles
- a minimal React widget rendered in the Paperclip dashboard
- reading host context (`companyId`) from `PluginWidgetProps`
- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
## API Surface
- This example does not add custom HTTP endpoints.
- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
## Notes
This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
## Local Install (Dev)
From the repo root, build the plugin and install it by local path:
```bash
pnpm --filter @paperclipai/plugin-hello-world-example build
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
```
**Local development notes:**
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.

View File

@@ -0,0 +1,35 @@
{
"name": "@paperclipai/plugin-hello-world-example",
"version": "0.1.0",
"description": "First-party reference plugin that adds a Hello World dashboard widget",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"scripts": {
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": ">=18"
}
}

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,39 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
/**
* Stable plugin ID used by host registration and namespacing.
*/
const PLUGIN_ID = "paperclip.hello-world-example";
const PLUGIN_VERSION = "0.1.0";
const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
/**
* Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
*/
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Hello World Widget (Example)",
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
author: "Paperclip",
categories: ["ui"],
capabilities: ["ui.dashboardWidget.register"],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
ui: {
slots: [
{
type: "dashboardWidget",
id: DASHBOARD_WIDGET_SLOT_ID,
displayName: "Hello World",
exportName: DASHBOARD_WIDGET_EXPORT_NAME,
},
],
},
};
export default manifest;

View File

@@ -0,0 +1,17 @@
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
const WIDGET_LABEL = "Hello world plugin widget";
/**
* Example dashboard widget showing the smallest possible UI contribution.
*/
export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
return (
<section aria-label={WIDGET_LABEL}>
<strong>Hello world</strong>
<div>This widget was added by @paperclipai/plugin-hello-world-example.</div>
{/* Include host context so authors can see where scoped IDs come from. */}
<div>Company context: {context.companyId}</div>
</section>
);
}

View File

@@ -0,0 +1,27 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
const PLUGIN_NAME = "hello-world-example";
const HEALTH_MESSAGE = "Hello World example plugin ready";
/**
* Worker lifecycle hooks for the Hello World reference plugin.
* This stays intentionally small so new authors can copy the shape quickly.
*/
const plugin = definePlugin({
/**
* Called when the host starts the plugin worker.
*/
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
},
/**
* Called by the host health probe endpoint.
*/
async onHealth() {
return { status: "ok", message: HEALTH_MESSAGE };
},
});
export default plugin;
runWorker(plugin, import.meta.url);

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -0,0 +1,33 @@
# @paperclipai/plugin-kitchen-sink-example
Kitchen Sink is the first-party reference plugin that demonstrates nearly the full currently implemented Paperclip plugin surface in one package.
It is intentionally broad:
- full plugin page
- dashboard widget
- project and issue surfaces
- comment surfaces
- sidebar surfaces
- settings page
- worker bridge data/actions
- events, jobs, webhooks, tools, streams
- state, entities, assets, metrics, activity
- local workspace and process demos
This plugin is for local development, contributor onboarding, and runtime regression testing. It is not meant as a production plugin template to ship unchanged.
## Install
```sh
pnpm --filter @paperclipai/plugin-kitchen-sink-example build
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
```
Or install it from the Paperclip plugin manager as a bundled example once this repo is built.
## Notes
- Local workspace and process demos are trusted-only and default to safe, curated commands.
- The plugin settings page lets you toggle optional demo surfaces and local runtime behavior.
- Some SDK-defined host surfaces still depend on the Paperclip host wiring them visibly; this package aims to exercise the currently mounted ones and make the rest obvious.

View File

@@ -0,0 +1,37 @@
{
"name": "@paperclipai/plugin-kitchen-sink-example",
"version": "0.1.0",
"description": "Reference plugin that demonstrates the full Paperclip plugin surface area in one package",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"scripts": {
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
"build": "tsc && node ./scripts/build-ui.mjs",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@paperclipai/shared": "workspace:*"
},
"devDependencies": {
"esbuild": "^0.27.3",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": ">=18"
}
}

View File

@@ -0,0 +1,24 @@
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, "..");
await esbuild.build({
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
outfile: path.join(packageRoot, "dist/ui/index.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@paperclipai/plugin-sdk/ui",
],
logLevel: "info",
});

View File

@@ -0,0 +1,113 @@
import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk";
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
export const PLUGIN_VERSION = "0.1.0";
export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = {
page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel",
projectSidebarItem: "kitchen-sink-project-link",
projectTab: "kitchen-sink-project-tab",
issueTab: "kitchen-sink-issue-tab",
taskDetailView: "kitchen-sink-task-detail",
toolbarButton: "kitchen-sink-toolbar-action",
contextMenuItem: "kitchen-sink-context-action",
commentAnnotation: "kitchen-sink-comment-annotation",
commentContextMenuItem: "kitchen-sink-comment-action",
} as const;
export const EXPORT_NAMES = {
page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel",
projectSidebarItem: "KitchenSinkProjectSidebarItem",
projectTab: "KitchenSinkProjectTab",
issueTab: "KitchenSinkIssueTab",
taskDetailView: "KitchenSinkTaskDetailView",
toolbarButton: "KitchenSinkToolbarButton",
contextMenuItem: "KitchenSinkContextMenuItem",
commentAnnotation: "KitchenSinkCommentAnnotation",
commentContextMenuItem: "KitchenSinkCommentContextMenuItem",
launcherModal: "KitchenSinkLauncherModal",
} as const;
export const JOB_KEYS = {
heartbeat: "demo-heartbeat",
} as const;
export const WEBHOOK_KEYS = {
demo: "demo-ingest",
} as const;
export const TOOL_NAMES = {
echo: "echo",
companySummary: "company-summary",
createIssue: "create-issue",
} as const;
export const STREAM_CHANNELS = {
progress: "progress",
agentChat: "agent-chat",
} as const;
export const SAFE_COMMANDS = [
{
key: "pwd",
label: "Print workspace path",
command: "pwd",
args: [] as string[],
description: "Prints the current workspace directory.",
},
{
key: "ls",
label: "List workspace files",
command: "ls",
args: ["-la"] as string[],
description: "Lists files in the selected workspace.",
},
{
key: "git-status",
label: "Git status",
command: "git",
args: ["status", "--short", "--branch"] as string[],
description: "Shows git status for the selected workspace.",
},
] as const;
export type SafeCommandKey = (typeof SAFE_COMMANDS)[number]["key"];
export const DEFAULT_CONFIG = {
showSidebarEntry: true,
showSidebarPanel: true,
showProjectSidebarItem: true,
showCommentAnnotation: true,
showCommentContextMenuItem: true,
enableWorkspaceDemos: true,
enableProcessDemos: false,
secretRefExample: "",
httpDemoUrl: "https://httpbin.org/anything",
allowedCommands: SAFE_COMMANDS.map((command) => command.key),
workspaceScratchFile: ".paperclip-kitchen-sink-demo.txt",
} as const;
export const RUNTIME_LAUNCHER: PluginLauncherRegistration = {
id: "kitchen-sink-runtime-launcher",
displayName: "Kitchen Sink Modal",
description: "Demonstrates runtime launcher registration from the worker.",
placementZone: "toolbarButton",
entityTypes: ["project", "issue"],
action: {
type: "openModal",
target: EXPORT_NAMES.launcherModal,
},
render: {
environment: "hostOverlay",
bounds: "wide",
},
};

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,290 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
import {
DEFAULT_CONFIG,
EXPORT_NAMES,
JOB_KEYS,
PAGE_ROUTE,
PLUGIN_ID,
PLUGIN_VERSION,
SLOT_IDS,
TOOL_NAMES,
WEBHOOK_KEYS,
} from "./constants.js";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Kitchen Sink (Example)",
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, UI surfaces, bridge actions, events, jobs, webhooks, tools, local workspace access, and runtime diagnostics in one place.",
author: "Paperclip",
categories: ["ui", "automation", "workspace", "connector"],
capabilities: [
"companies.read",
"projects.read",
"project.workspaces.read",
"issues.read",
"issues.create",
"issues.update",
"issue.comments.read",
"issue.comments.create",
"agents.read",
"agents.pause",
"agents.resume",
"agents.invoke",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
"agent.sessions.close",
"goals.read",
"goals.create",
"goals.update",
"activity.log.write",
"metrics.write",
"plugin.state.read",
"plugin.state.write",
"events.subscribe",
"events.emit",
"jobs.schedule",
"webhooks.receive",
"http.outbound",
"secrets.read-ref",
"agent.tools.register",
"instance.settings.register",
"ui.sidebar.register",
"ui.page.register",
"ui.detailTab.register",
"ui.dashboardWidget.register",
"ui.commentAnnotation.register",
"ui.action.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
instanceConfigSchema: {
type: "object",
properties: {
showSidebarEntry: {
type: "boolean",
title: "Show Sidebar Entry",
default: DEFAULT_CONFIG.showSidebarEntry,
},
showSidebarPanel: {
type: "boolean",
title: "Show Sidebar Panel",
default: DEFAULT_CONFIG.showSidebarPanel,
},
showProjectSidebarItem: {
type: "boolean",
title: "Show Project Sidebar Item",
default: DEFAULT_CONFIG.showProjectSidebarItem,
},
showCommentAnnotation: {
type: "boolean",
title: "Show Comment Annotation",
default: DEFAULT_CONFIG.showCommentAnnotation,
},
showCommentContextMenuItem: {
type: "boolean",
title: "Show Comment Action",
default: DEFAULT_CONFIG.showCommentContextMenuItem,
},
enableWorkspaceDemos: {
type: "boolean",
title: "Enable Workspace Demos",
default: DEFAULT_CONFIG.enableWorkspaceDemos,
},
enableProcessDemos: {
type: "boolean",
title: "Enable Process Demos",
default: DEFAULT_CONFIG.enableProcessDemos,
description: "Allows curated local child-process demos in project workspaces.",
},
secretRefExample: {
type: "string",
title: "Secret Reference Example",
default: DEFAULT_CONFIG.secretRefExample,
},
httpDemoUrl: {
type: "string",
title: "HTTP Demo URL",
default: DEFAULT_CONFIG.httpDemoUrl,
},
allowedCommands: {
type: "array",
title: "Allowed Process Commands",
items: {
type: "string",
enum: DEFAULT_CONFIG.allowedCommands,
},
default: DEFAULT_CONFIG.allowedCommands,
},
workspaceScratchFile: {
type: "string",
title: "Workspace Scratch File",
default: DEFAULT_CONFIG.workspaceScratchFile,
},
},
},
jobs: [
{
jobKey: JOB_KEYS.heartbeat,
displayName: "Demo Heartbeat",
description: "Periodic demo job that records plugin runtime activity.",
schedule: "*/15 * * * *",
},
],
webhooks: [
{
endpointKey: WEBHOOK_KEYS.demo,
displayName: "Demo Ingest",
description: "Accepts arbitrary webhook payloads and records the latest delivery in plugin state.",
},
],
tools: [
{
name: TOOL_NAMES.echo,
displayName: "Kitchen Sink Echo",
description: "Returns the provided message and the current run context.",
parametersSchema: {
type: "object",
properties: {
message: { type: "string" },
},
required: ["message"],
},
},
{
name: TOOL_NAMES.companySummary,
displayName: "Kitchen Sink Company Summary",
description: "Summarizes the current company using the Paperclip domain APIs.",
parametersSchema: {
type: "object",
properties: {},
},
},
{
name: TOOL_NAMES.createIssue,
displayName: "Kitchen Sink Create Issue",
description: "Creates an issue in the current project from an agent tool call.",
parametersSchema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
},
required: ["title"],
},
},
],
ui: {
slots: [
{
type: "page",
id: SLOT_IDS.page,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.page,
routePath: PAGE_ROUTE,
},
{
type: "settingsPage",
id: SLOT_IDS.settingsPage,
displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage,
},
{
type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.dashboardWidget,
},
{
type: "sidebar",
id: SLOT_IDS.sidebar,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.sidebar,
},
{
type: "sidebarPanel",
id: SLOT_IDS.sidebarPanel,
displayName: "Kitchen Sink Panel",
exportName: EXPORT_NAMES.sidebarPanel,
},
{
type: "projectSidebarItem",
id: SLOT_IDS.projectSidebarItem,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.projectSidebarItem,
entityTypes: ["project"],
},
{
type: "detailTab",
id: SLOT_IDS.projectTab,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.projectTab,
entityTypes: ["project"],
},
{
type: "detailTab",
id: SLOT_IDS.issueTab,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.issueTab,
entityTypes: ["issue"],
},
{
type: "taskDetailView",
id: SLOT_IDS.taskDetailView,
displayName: "Kitchen Sink Task View",
exportName: EXPORT_NAMES.taskDetailView,
entityTypes: ["issue"],
},
{
type: "toolbarButton",
id: SLOT_IDS.toolbarButton,
displayName: "Kitchen Sink Action",
exportName: EXPORT_NAMES.toolbarButton,
entityTypes: ["project", "issue"],
},
{
type: "contextMenuItem",
id: SLOT_IDS.contextMenuItem,
displayName: "Kitchen Sink Context",
exportName: EXPORT_NAMES.contextMenuItem,
entityTypes: ["project", "issue"],
},
{
type: "commentAnnotation",
id: SLOT_IDS.commentAnnotation,
displayName: "Kitchen Sink Comment Annotation",
exportName: EXPORT_NAMES.commentAnnotation,
entityTypes: ["comment"],
},
{
type: "commentContextMenuItem",
id: SLOT_IDS.commentContextMenuItem,
displayName: "Kitchen Sink Comment Action",
exportName: EXPORT_NAMES.commentContextMenuItem,
entityTypes: ["comment"],
},
],
launchers: [
{
id: "kitchen-sink-launcher",
displayName: "Kitchen Sink Modal",
placementZone: "toolbarButton",
entityTypes: ["project", "issue"],
action: {
type: "openModal",
target: EXPORT_NAMES.launcherModal,
},
render: {
environment: "hostOverlay",
bounds: "wide",
},
},
],
},
};
export default manifest;

View File

@@ -0,0 +1,363 @@
import { useEffect, useRef } from "react";
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
const TARGET_FPS = 24;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
const PAPERCLIP_SPRITES = [
[
" ╭────╮ ",
" ╭╯╭──╮│ ",
" │ │ ││ ",
" │ │ ││ ",
" │ │ ││ ",
" │ │ ││ ",
" │ ╰──╯│ ",
" ╰─────╯ ",
],
[
" ╭─────╮ ",
" │╭──╮╰╮ ",
" ││ │ │ ",
" ││ │ │ ",
" ││ │ │ ",
" ││ │ │ ",
" │╰──╯ │ ",
" ╰────╯ ",
],
] as const;
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
interface Clip {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
drift: number;
sprite: PaperclipSprite;
width: number;
height: number;
}
function measureChar(container: HTMLElement): { w: number; h: number } {
const span = document.createElement("span");
span.textContent = "M";
span.style.cssText =
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
container.appendChild(span);
const rect = span.getBoundingClientRect();
container.removeChild(span);
return { w: rect.width, h: rect.height };
}
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
let width = 0;
for (const row of sprite) width = Math.max(width, row.length);
return { width, height: sprite.length };
}
export function AsciiArtAnimation() {
const preRef = useRef<HTMLPreElement>(null);
const frameRef = useRef<number | null>(null);
useEffect(() => {
if (!preRef.current) return;
const preEl: HTMLPreElement = preRef.current;
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
let isVisible = document.visibilityState !== "hidden";
let loopActive = false;
let lastRenderAt = 0;
let tick = 0;
let cols = 0;
let rows = 0;
let charW = 7;
let charH = 11;
let trail = new Float32Array(0);
let colWave = new Float32Array(0);
let rowWave = new Float32Array(0);
let clipMask = new Uint16Array(0);
let clips: Clip[] = [];
let lastOutput = "";
function toGlyph(value: number): string {
const clamped = Math.max(0, Math.min(0.999, value));
const idx = Math.floor(clamped * CHARS.length);
return CHARS[idx] ?? " ";
}
function rebuildGrid() {
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
if (nextCols === cols && nextRows === rows) return;
cols = nextCols;
rows = nextRows;
const cellCount = cols * rows;
trail = new Float32Array(cellCount);
colWave = new Float32Array(cols);
rowWave = new Float32Array(rows);
clipMask = new Uint16Array(cellCount);
clips = clips.filter((clip) => {
return (
clip.x > -clip.width - 2 &&
clip.x < cols + 2 &&
clip.y > -clip.height - 2 &&
clip.y < rows + 2
);
});
lastOutput = "";
}
function drawStaticFrame() {
if (cols <= 0 || rows <= 0) {
preEl.textContent = "";
return;
}
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
grid[r]![c] = toGlyph(ambient);
}
}
const gapX = 18;
const gapY = 13;
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
for (let sr = 0; sr < sprite.length; sr++) {
const line = sprite[sr]!;
for (let sc = 0; sc < line.length; sc++) {
const ch = line[sc] ?? " ";
if (ch === " ") continue;
const row = baseRow + sr;
const col = baseCol + sc;
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
grid[row]![col] = ch;
}
}
}
}
const output = grid.map((line) => line.join("")).join("\n");
preEl.textContent = output;
lastOutput = output;
}
function spawnClip() {
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
const size = spriteSize(sprite);
const edge = Math.random();
let x = 0;
let y = 0;
let vx = 0;
let vy = 0;
if (edge < 0.68) {
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
y = Math.random() * Math.max(1, rows - size.height);
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
vy = (Math.random() - 0.5) * 0.014;
} else {
x = Math.random() * Math.max(1, cols - size.width);
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
vx = (Math.random() - 0.5) * 0.014;
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
}
clips.push({
x,
y,
vx,
vy,
life: 0,
maxLife: 260 + Math.random() * 220,
drift: (Math.random() - 0.5) * 1.2,
sprite,
width: size.width,
height: size.height,
});
}
function stampClip(clip: Clip, alpha: number) {
const baseCol = Math.round(clip.x);
const baseRow = Math.round(clip.y);
for (let sr = 0; sr < clip.sprite.length; sr++) {
const line = clip.sprite[sr]!;
const row = baseRow + sr;
if (row < 0 || row >= rows) continue;
for (let sc = 0; sc < line.length; sc++) {
const ch = line[sc] ?? " ";
if (ch === " ") continue;
const col = baseCol + sc;
if (col < 0 || col >= cols) continue;
const idx = row * cols + col;
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
clipMask[idx] = ch.charCodeAt(0);
}
}
}
function step(time: number) {
if (!loopActive) return;
frameRef.current = requestAnimationFrame(step);
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
lastRenderAt = time;
tick += delta;
const cellCount = cols * rows;
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
while (clips.length < targetCount) spawnClip();
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
clipMask.fill(0);
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i]!;
clip.life += delta;
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
clip.vx = (clip.vx + wobbleX) * 0.998;
clip.vy = (clip.vy + wobbleY) * 0.998;
clip.x += clip.vx * delta;
clip.y += clip.vy * delta;
if (
clip.life >= clip.maxLife ||
clip.x < -clip.width - 2 ||
clip.x > cols + 2 ||
clip.y < -clip.height - 2 ||
clip.y > rows + 2
) {
clips.splice(i, 1);
continue;
}
const life = clip.life / clip.maxLife;
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
stampClip(clip, alpha);
}
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
let output = "";
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
const clipChar = clipMask[idx];
if (clipChar > 0) {
output += String.fromCharCode(clipChar);
continue;
}
const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05;
output += toGlyph((trail[idx] ?? 0) + ambient);
}
if (r < rows - 1) output += "\n";
}
if (output !== lastOutput) {
preEl.textContent = output;
lastOutput = output;
}
}
const resizeObserver = new ResizeObserver(() => {
const measured = measureChar(preEl);
charW = measured.w || 7;
charH = measured.h || 11;
rebuildGrid();
if (motionMedia.matches || !isVisible) {
drawStaticFrame();
}
});
function startLoop() {
if (loopActive) return;
loopActive = true;
lastRenderAt = 0;
frameRef.current = requestAnimationFrame(step);
}
function stopLoop() {
loopActive = false;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}
function syncMode() {
if (motionMedia.matches || !isVisible) {
stopLoop();
drawStaticFrame();
} else {
startLoop();
}
}
function handleVisibility() {
isVisible = document.visibilityState !== "hidden";
syncMode();
}
const measured = measureChar(preEl);
charW = measured.w || 7;
charH = measured.h || 11;
rebuildGrid();
resizeObserver.observe(preEl);
motionMedia.addEventListener("change", syncMode);
document.addEventListener("visibilitychange", handleVisibility);
syncMode();
return () => {
stopLoop();
resizeObserver.disconnect();
motionMedia.removeEventListener("change", syncMode);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, []);
return (
<div
style={{
height: "320px",
minHeight: "320px",
maxHeight: "350px",
background: "#1d1d1d",
color: "#f2efe6",
overflow: "hidden",
borderRadius: "12px",
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
}}
>
<pre
ref={preRef}
aria-hidden="true"
style={{
margin: 0,
width: "100%",
height: "100%",
padding: "14px",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
fontSize: "11px",
lineHeight: 1,
whiteSpace: "pre",
userSelect: "none",
}}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -0,0 +1,873 @@
# `@paperclipai/plugin-sdk`
Official TypeScript SDK for Paperclip plugin authors.
- **Worker SDK:** `@paperclipai/plugin-sdk``definePlugin`, context, lifecycle
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
Reference: `doc/plugins/PLUGIN_SPEC.md`
## Package surface
| Import | Purpose |
|--------|--------|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
## Manifest entrypoints
In your plugin manifest you declare:
- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
## Install
```bash
pnpm add @paperclipai/plugin-sdk
```
## Current deployment caveats
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
- Plugin workers and plugin UI should both be treated as trusted code today.
- Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
## Worker quick start
```ts
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
const plugin = definePlugin({
async setup(ctx) {
ctx.events.on("issue.created", async (event) => {
ctx.logger.info("Issue created", { issueId: event.entityId });
});
ctx.data.register("health", async () => ({ status: "ok" }));
ctx.actions.register("ping", async () => ({ pong: true }));
ctx.tools.register("calculator", {
displayName: "Calculator",
description: "Basic math",
parametersSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "number" } },
required: ["a", "b"]
}
}, async (params) => {
const { a, b } = params as { a: number; b: number };
return { content: `Result: ${a + b}`, data: { result: a + b } };
});
},
});
export default plugin;
runWorker(plugin, import.meta.url);
```
**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.
### Worker lifecycle and context
**Lifecycle (definePlugin):**
| Hook | Purpose |
|------|--------|
| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
## Events
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
**Core domain events (subscribe with `events.subscribe`):**
| Event | Typical entity |
|-------|-----------------|
| `company.created`, `company.updated` | company |
| `project.created`, `project.updated` | project |
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
| `agent.created`, `agent.updated`, `agent.status_changed` | agent |
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
| `goal.created`, `goal.updated` | goal |
| `approval.created`, `approval.decided` | approval |
| `cost_event.created` | cost |
| `activity.logged` | activity |
**Plugin-to-plugin:** Subscribe to `plugin.<pluginId>.<eventName>` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically.
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
## Scheduled (recurring) jobs
Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
| Field | Values | Example |
|-------------|----------|---------|
| minute | 059 | `0`, `*/15` |
| hour | 023 | `2`, `*` |
| day of month | 131 | `1`, `*` |
| month | 112 | `*` |
| day of week | 06 (Sun=0) | `*`, `1-5` |
Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
**Job handler context** (`PluginJobContext`):
| Field | Type | Description |
|-------------|----------|-------------|
| `jobKey` | string | Matches the manifest declaration. |
| `runId` | string | UUID for this run. |
| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
Example:
**Manifest** — include `jobs.schedule` and declare the job:
```ts
// In your manifest (e.g. manifest.ts):
const manifest = {
// ...
capabilities: ["jobs.schedule", "plugin.state.write"],
jobs: [
{
jobKey: "heartbeat",
displayName: "Heartbeat",
description: "Runs every 5 minutes",
schedule: "*/5 * * * *",
},
],
// ...
};
```
**Worker** — register the handler in `setup()`:
```ts
ctx.jobs.register("heartbeat", async (job) => {
ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
});
```
## UI slots and launchers
Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).
### Slot types / launcher placement zones
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|----------------------------|-------|---------------------------------------|
| `page` | Global | — |
| `sidebar` | Global | — |
| `sidebarPanel` | Global | — |
| `settingsPage` | Global | — |
| `dashboardWidget` | Global | — |
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
| `taskDetailView` | Entity | (task/issue context) |
| `commentAnnotation` | Entity | `comment` |
| `commentContextMenuItem` | Entity | `comment` |
| `projectSidebarItem` | Entity | `project` |
| `toolbarButton` | Entity | varies by host surface |
| `contextMenuItem` | Entity | varies by host surface |
**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue).
**Entity types** (for `entityTypes` on slots): `project` \| `issue` \| `agent` \| `goal` \| `run` \| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@paperclipai/plugin-sdk`.
### Slot component descriptions
#### `page`
A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability.
#### `sidebar`
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
#### `sidebarPanel`
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
#### `settingsPage`
Replaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, "Test Connection" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`).
#### `dashboardWidget`
A card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Paperclip information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability.
#### `detailTab`
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
#### `taskDetailView`
A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
#### `projectSidebarItem`
A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
#### `toolbarButton`
A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
#### `contextMenuItem`
An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
#### `commentAnnotation`
A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.
#### `commentContextMenuItem`
A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.
### Launcher actions and render options
| Launcher action | Description |
|-----------------|-------------|
| `navigate` | Navigate to a route (plugin or host). |
| `openModal` | Open a modal. |
| `openDrawer` | Open a drawer. |
| `openPopover` | Open a popover. |
| `performAction` | Run an action (e.g. call plugin). |
| `deepLink` | Deep link to plugin or external URL. |
| Render option | Values | Description |
|---------------|--------|-------------|
| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
### Capabilities
Declare in `manifest.capabilities`. Grouped by scope:
| Scope | Capability |
|-------|------------|
| **Company** | `companies.read` |
| | `projects.read` |
| | `project.workspaces.read` |
| | `issues.read` |
| | `issue.comments.read` |
| | `agents.read` |
| | `goals.read` |
| | `goals.create` |
| | `goals.update` |
| | `activity.read` |
| | `costs.read` |
| | `issues.create` |
| | `issues.update` |
| | `issue.comments.create` |
| | `activity.log.write` |
| | `metrics.write` |
| **Instance** | `instance.settings.register` |
| | `plugin.state.read` |
| | `plugin.state.write` |
| **Runtime** | `events.subscribe` |
| | `events.emit` |
| | `jobs.schedule` |
| | `webhooks.receive` |
| | `http.outbound` |
| | `secrets.read-ref` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `agent.sessions.create` |
| | `agent.sessions.list` |
| | `agent.sessions.send` |
| | `agent.sessions.close` |
| **UI** | `ui.sidebar.register` |
| | `ui.page.register` |
| | `ui.detailTab.register` |
| | `ui.dashboardWidget.register` |
| | `ui.commentAnnotation.register` |
| | `ui.action.register` |
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
## UI quick start
```tsx
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
export function DashboardWidget() {
const { data } = usePluginData<{ status: string }>("health");
const ping = usePluginAction("ping");
return (
<div style={{ display: "grid", gap: 8 }}>
<strong>Health</strong>
<div>{data?.status ?? "unknown"}</div>
<button onClick={() => void ping()}>Ping</button>
</div>
);
}
```
### Hooks reference
#### `usePluginData<T>(key, params?)`
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
```tsx
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
interface SyncStatus {
lastSyncAt: string;
syncedCount: number;
healthy: boolean;
}
export function SyncStatusWidget({ context }: PluginWidgetProps) {
const { data, loading, error, refresh } = usePluginData<SyncStatus>("sync-status", {
companyId: context.companyId,
});
if (loading) return <div>Loading</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
<p>Synced {data!.syncedCount} items</p>
<p>Last sync: {data!.lastSyncAt}</p>
<button onClick={refresh}>Refresh</button>
</div>
);
}
```
#### `usePluginAction(key)`
Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
```tsx
import { useState } from "react";
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
export function ResyncButton({ context }: PluginWidgetProps) {
const resync = usePluginAction("resync");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setBusy(true);
setError(null);
try {
await resync({ companyId: context.companyId });
} catch (err) {
setError((err as PluginBridgeError).message);
} finally {
setBusy(false);
}
}
return (
<div>
<button onClick={handleClick} disabled={busy}>
{busy ? "Syncing..." : "Resync Now"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
```
#### `useHostContext()`
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
```tsx
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
export function IssueLinearLink({ context }: PluginDetailTabProps) {
const { companyId, entityId, entityType } = context;
const { data } = usePluginData<{ url: string }>("linear-link", {
companyId,
issueId: entityId,
});
if (!data?.url) return <p>No linked Linear issue.</p>;
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
}
```
#### `usePluginStream<T>(channel, options?)`
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
```tsx
import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
interface ChatToken {
text: string;
}
export function ChatMessages({ context }: PluginWidgetProps) {
const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
companyId: context.companyId ?? undefined,
});
return (
<div>
{events.map((e, i) => <span key={i}>{e.text}</span>)}
{connected && <span className="pulse" />}
<button onClick={close}>Stop</button>
</div>
);
}
```
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
### UI authoring note
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
### Slot component props
Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`.
| Slot type | Props interface | `context` extras |
|-----------|----------------|------------------|
| `page` | `PluginPageProps` | — |
| `sidebar` | `PluginSidebarProps` | — |
| `settingsPage` | `PluginSettingsPageProps` | — |
| `dashboardWidget` | `PluginWidgetProps` | — |
| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
Example detail tab with entity context:
```tsx
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
agentId: context.entityId,
companyId: context.companyId,
});
if (loading) return <div>Loading</div>;
if (!data) return <p>No metrics available.</p>;
return (
<dl>
{Object.entries(data).map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
```
## Launcher surfaces and modals
V1 does not provide a dedicated `modal` slot. Plugins can either:
- declare concrete UI mount points in `ui.slots`
- declare host-rendered entry points in `ui.launchers`
Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
Declarative launcher example:
```json
{
"ui": {
"launchers": [
{
"id": "sync-project",
"displayName": "Sync",
"placementZone": "toolbarButton",
"entityTypes": ["project"],
"action": {
"type": "openDrawer",
"target": "sync-project"
},
"render": {
"environment": "hostOverlay",
"bounds": "wide"
}
}
]
}
}
```
The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
When a launcher opens a host-owned overlay or page, `useHostContext()`,
`usePluginData()`, and `usePluginAction()` receive the current
`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
full-page layouts without adding custom route parsing in the plugin.
## Project sidebar item
Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that projects id in `context.entityId`. Declare the slot and capability in your manifest:
```json
{
"ui": {
"slots": [
{
"type": "projectSidebarItem",
"id": "files",
"displayName": "Files",
"exportName": "FilesLink",
"entityTypes": ["project"]
}
]
},
"capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
}
```
Minimal React component that links to the projects plugin tab (see project detail tabs in the spec):
```tsx
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
const projectId = context.entityId;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const projectRef = projectId; // or resolve from host; entityId is project id
return (
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
Files
</a>
);
}
```
Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.
## Toolbar launcher with a local modal
For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
```json
{
"ui": {
"slots": [
{
"type": "toolbarButton",
"id": "sync-toolbar-button",
"displayName": "Sync",
"exportName": "SyncToolbarButton"
}
]
},
"capabilities": ["ui.action.register"]
}
```
```tsx
import { useState } from "react";
import {
useHostContext,
usePluginAction,
} from "@paperclipai/plugin-sdk/ui";
export function SyncToolbarButton() {
const context = useHostContext();
const syncProject = usePluginAction("sync-project");
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function confirm() {
if (!context.projectId) return;
setSubmitting(true);
setErrorMessage(null);
try {
await syncProject({ projectId: context.projectId });
setOpen(false);
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Sync failed");
} finally {
setSubmitting(false);
}
}
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Sync
</button>
{open ? (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => !submitting && setOpen(false)}
>
<div
className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-base font-semibold">Sync this project?</h2>
<p className="mt-2 text-sm text-muted-foreground">
Queue a sync for <code>{context.projectId}</code>.
</p>
{errorMessage ? (
<p className="mt-2 text-sm text-destructive">{errorMessage}</p>
) : null}
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)}>
Cancel
</button>
<button type="button" onClick={() => void confirm()} disabled={submitting}>
{submitting ? "Running…" : "Run sync"}
</button>
</div>
</div>
</div>
) : null}
</>
);
}
```
Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
## Real-time streaming (`ctx.streams`)
Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
### Worker side
In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
```ts
const plugin = definePlugin({
async setup(ctx) {
ctx.actions.register("chat", async (params) => {
const companyId = params.companyId as string;
ctx.streams.open("chat-stream", companyId);
for await (const token of streamFromLLM(params.prompt as string)) {
ctx.streams.emit("chat-stream", { text: token });
}
ctx.streams.close("chat-stream");
return { ok: true };
});
},
});
```
**API:**
| Method | Description |
|--------|-------------|
| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
### UI side
Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
### Host-side architecture
The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
1. Worker emits `streams.emit` notification via stdout
2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
### Streaming agent responses to the UI
`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
```
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
```
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
**Worker:**
```ts
ctx.actions.register("ask-agent", async (params) => {
const { agentId, companyId, prompt } = params as {
agentId: string; companyId: string; prompt: string;
};
const channel = `agent:${agentId}`;
ctx.streams.open(channel, companyId);
const session = await ctx.agents.sessions.create(agentId, companyId);
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt,
onEvent: (event) => {
ctx.streams.emit(channel, {
type: event.eventType, // "chunk" | "done" | "error"
text: event.message ?? "",
});
},
});
ctx.streams.close(channel);
return { sessionId: session.sessionId };
});
```
**UI:**
```tsx
import { useState } from "react";
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
interface AgentEvent {
type: "chunk" | "done" | "error";
text: string;
}
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
const askAgent = usePluginAction("ask-agent");
const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
const [prompt, setPrompt] = useState("");
async function send() {
setPrompt("");
await askAgent({ agentId, companyId, prompt });
}
return (
<div>
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={send}>Send</button>
{connected && <button onClick={close}>Stop</button>}
</div>
);
}
```
## Agent sessions (two-way chat)
Plugins can hold multi-turn conversational sessions with agents:
```ts
// Create a session
const session = await ctx.agents.sessions.create(agentId, companyId);
// Send a message and stream the response
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt: "Help me triage this issue",
onEvent: (event) => {
if (event.eventType === "chunk") console.log(event.message);
if (event.eventType === "done") console.log("Stream complete");
},
});
// List active sessions
const sessions = await ctx.agents.sessions.list(agentId, companyId);
// Close when done
await ctx.agents.sessions.close(session.sessionId, companyId);
```
Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
## Testing utilities
```ts
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import plugin from "../src/worker.js";
import manifest from "../src/manifest.js";
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
```
## Bundler presets
```ts
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
```
## Local dev server (hot-reload events)
```bash
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
```
Or programmatically:
```ts
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
const server = await startPluginDevServer({ rootDir: process.cwd() });
```
Dev server endpoints:
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes

View File

@@ -0,0 +1,116 @@
{
"name": "@paperclipai/plugin-sdk",
"version": "1.0.0",
"description": "Stable public API for Paperclip plugins — worker-side context and UI bridge hooks",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./protocol": {
"types": "./dist/protocol.d.ts",
"import": "./dist/protocol.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./ui/hooks": {
"types": "./dist/ui/hooks.d.ts",
"import": "./dist/ui/hooks.js"
},
"./ui/types": {
"types": "./dist/ui/types.d.ts",
"import": "./dist/ui/types.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
},
"./bundlers": {
"types": "./dist/bundlers.d.ts",
"import": "./dist/bundlers.js"
},
"./dev-server": {
"types": "./dist/dev-server.d.ts",
"import": "./dist/dev-server.js"
}
},
"bin": {
"paperclip-plugin-dev-server": "./dist/dev-cli.js"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./protocol": {
"types": "./dist/protocol.d.ts",
"import": "./dist/protocol.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./ui/hooks": {
"types": "./dist/ui/hooks.d.ts",
"import": "./dist/ui/hooks.js"
},
"./ui/types": {
"types": "./dist/ui/types.d.ts",
"import": "./dist/ui/types.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
},
"./bundlers": {
"types": "./dist/bundlers.d.ts",
"import": "./dist/bundlers.js"
},
"./dev-server": {
"types": "./dist/dev-server.d.ts",
"import": "./dist/dev-server.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "pnpm --filter @paperclipai/shared build && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit",
"dev:server": "tsx src/dev-cli.ts"
},
"dependencies": {
"@paperclipai/shared": "workspace:*",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": ">=18"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}

View File

@@ -0,0 +1,160 @@
/**
* Bundling presets for Paperclip plugins.
*
* These helpers return plain config objects so plugin authors can use them
* with esbuild or rollup without re-implementing host contract defaults.
*/
export interface PluginBundlerPresetInput {
pluginRoot?: string;
manifestEntry?: string;
workerEntry?: string;
uiEntry?: string;
outdir?: string;
sourcemap?: boolean;
minify?: boolean;
}
export interface EsbuildLikeOptions {
entryPoints: string[];
outdir: string;
bundle: boolean;
format: "esm";
platform: "node" | "browser";
target: string;
sourcemap?: boolean;
minify?: boolean;
external?: string[];
}
export interface RollupLikeConfig {
input: string;
output: {
dir: string;
format: "es";
sourcemap?: boolean;
entryFileNames?: string;
};
external?: string[];
plugins?: unknown[];
}
export interface PluginBundlerPresets {
esbuild: {
worker: EsbuildLikeOptions;
ui?: EsbuildLikeOptions;
manifest: EsbuildLikeOptions;
};
rollup: {
worker: RollupLikeConfig;
ui?: RollupLikeConfig;
manifest: RollupLikeConfig;
};
}
/**
* Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
*
* The presets intentionally externalize host/runtime deps (`react`, SDK packages)
* to match the Paperclip plugin loader contract.
*/
export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
const uiExternal = [
"@paperclipai/plugin-sdk/ui",
"@paperclipai/plugin-sdk/ui/hooks",
"react",
"react-dom",
"react/jsx-runtime",
];
const outdir = input.outdir ?? "dist";
const workerEntry = input.workerEntry ?? "src/worker.ts";
const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
const uiEntry = input.uiEntry;
const sourcemap = input.sourcemap ?? true;
const minify = input.minify ?? false;
const esbuildWorker: EsbuildLikeOptions = {
entryPoints: [workerEntry],
outdir,
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
minify,
external: ["react", "react-dom"],
};
const esbuildManifest: EsbuildLikeOptions = {
entryPoints: [manifestEntry],
outdir,
bundle: false,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
};
const esbuildUi = uiEntry
? {
entryPoints: [uiEntry],
outdir: `${outdir}/ui`,
bundle: true,
format: "esm" as const,
platform: "browser" as const,
target: "es2022",
sourcemap,
minify,
external: uiExternal,
}
: undefined;
const rollupWorker: RollupLikeConfig = {
input: workerEntry,
output: {
dir: outdir,
format: "es",
sourcemap,
entryFileNames: "worker.js",
},
external: ["react", "react-dom"],
};
const rollupManifest: RollupLikeConfig = {
input: manifestEntry,
output: {
dir: outdir,
format: "es",
sourcemap,
entryFileNames: "manifest.js",
},
external: ["@paperclipai/plugin-sdk"],
};
const rollupUi = uiEntry
? {
input: uiEntry,
output: {
dir: `${outdir}/ui`,
format: "es" as const,
sourcemap,
entryFileNames: "index.js",
},
external: uiExternal,
}
: undefined;
return {
esbuild: {
worker: esbuildWorker,
manifest: esbuildManifest,
...(esbuildUi ? { ui: esbuildUi } : {}),
},
rollup: {
worker: rollupWorker,
manifest: rollupManifest,
...(rollupUi ? { ui: rollupUi } : {}),
},
};
}

View File

@@ -0,0 +1,255 @@
/**
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
*
* Plugin authors call `definePlugin()` and export the result as the default
* export from their worker entrypoint. The host imports the worker module,
* calls `setup()` with a `PluginContext`, and from that point the plugin
* responds to events, jobs, webhooks, and UI requests through the context.
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*
* @example
* ```ts
* // dist/worker.ts
* import { definePlugin } from "@paperclipai/plugin-sdk";
*
* export default definePlugin({
* async setup(ctx) {
* ctx.logger.info("Linear sync plugin starting");
*
* // Subscribe to events
* ctx.events.on("issue.created", async (event) => {
* const config = await ctx.config.get();
* await ctx.http.fetch(`https://api.linear.app/...`, {
* method: "POST",
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
* body: JSON.stringify({ title: event.payload.title }),
* });
* });
*
* // Register a job handler
* ctx.jobs.register("full-sync", async (job) => {
* ctx.logger.info("Running full-sync job", { runId: job.runId });
* // ... sync logic
* });
*
* // Register data for the UI
* ctx.data.register("sync-health", async ({ companyId }) => {
* const state = await ctx.state.get({
* scopeKind: "company",
* scopeId: String(companyId),
* stateKey: "last-sync",
* });
* return { lastSync: state };
* });
* },
* });
* ```
*/
import type { PluginContext } from "./types.js";
// ---------------------------------------------------------------------------
// Health check result
// ---------------------------------------------------------------------------
/**
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
*
* @see PLUGIN_SPEC.md §13.2 — `health`
*/
export interface PluginHealthDiagnostics {
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
status: "ok" | "degraded" | "error";
/** Human-readable description of the current health state. */
message?: string;
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
details?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Config validation result
// ---------------------------------------------------------------------------
/**
* Result returned from the `validateConfig()` RPC method.
*
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
*/
export interface PluginConfigValidationResult {
/** Whether the config is valid. */
ok: boolean;
/** Non-fatal warnings about the config. */
warnings?: string[];
/** Validation errors (populated when `ok` is `false`). */
errors?: string[];
}
// ---------------------------------------------------------------------------
// Webhook handler input
// ---------------------------------------------------------------------------
/**
* Input received by the plugin worker's `handleWebhook` handler.
*
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
*/
export interface PluginWebhookInput {
/** Endpoint key matching the manifest declaration. */
endpointKey: string;
/** Inbound request headers. */
headers: Record<string, string | string[]>;
/** Raw request body as a UTF-8 string. */
rawBody: string;
/** Parsed JSON body (if applicable and parseable). */
parsedBody?: unknown;
/** Unique request identifier for idempotency checks. */
requestId: string;
}
// ---------------------------------------------------------------------------
// Plugin definition
// ---------------------------------------------------------------------------
/**
* The plugin definition shape passed to `definePlugin()`.
*
* The only required field is `setup`, which receives the `PluginContext` and
* is where the plugin registers its handlers (events, jobs, data, actions,
* tools, etc.).
*
* All other lifecycle hooks are optional. If a hook is not implemented the
* host applies default behaviour (e.g. restarting the worker on config change
* instead of calling `onConfigChanged`).
*
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
*/
export interface PluginDefinition {
/**
* Called once when the plugin worker starts up, after `initialize` completes.
*
* This is where the plugin registers all its handlers: event subscriptions,
* job handlers, data/action handlers, and tool registrations. Registration
* must be synchronous after `setup` resolves — do not register handlers
* inside async callbacks that may resolve after `setup` returns.
*
* @param ctx - The full plugin context provided by the host
*/
setup(ctx: PluginContext): Promise<void>;
/**
* Called when the host wants to know if the plugin is healthy.
*
* The host polls this on a regular interval and surfaces the result in the
* plugin health dashboard. If not implemented, the host infers health from
* worker process liveness.
*
* @see PLUGIN_SPEC.md §13.2 — `health`
*/
onHealth?(): Promise<PluginHealthDiagnostics>;
/**
* Called when the operator updates the plugin's instance configuration at
* runtime, without restarting the worker.
*
* If not implemented, the host restarts the worker to apply the new config.
*
* @param newConfig - The newly resolved configuration
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
*/
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
/**
* Called when the host is about to shut down the plugin worker.
*
* The worker has at most 10 seconds (configurable via plugin config) to
* finish in-flight work and resolve this promise. After the deadline the
* host sends SIGTERM, then SIGKILL.
*
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
*/
onShutdown?(): Promise<void>;
/**
* Called to validate the current plugin configuration.
*
* The host calls this:
* - after the plugin starts (to surface config errors immediately)
* - after the operator saves a new config (to validate before persisting)
* - via the "Test Connection" button in the settings UI
*
* @param config - The configuration to validate
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
*/
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
/**
* Called to handle an inbound webhook delivery.
*
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
* this handler. The plugin is responsible for signature verification using
* a resolved secret ref.
*
* If not implemented but webhooks are declared in the manifest, the host
* returns HTTP 501 for webhook deliveries.
*
* @param input - Webhook delivery metadata and payload
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
*/
onWebhook?(input: PluginWebhookInput): Promise<void>;
}
// ---------------------------------------------------------------------------
// PaperclipPlugin — the sealed object returned by definePlugin()
// ---------------------------------------------------------------------------
/**
* The sealed plugin object returned by `definePlugin()`.
*
* Plugin authors export this as the default export from their worker
* entrypoint. The host imports it and calls the lifecycle methods.
*
* @see PLUGIN_SPEC.md §14 — SDK Surface
*/
export interface PaperclipPlugin {
/** The original plugin definition passed to `definePlugin()`. */
readonly definition: PluginDefinition;
}
// ---------------------------------------------------------------------------
// definePlugin — top-level factory
// ---------------------------------------------------------------------------
/**
* Define a Paperclip plugin.
*
* Call this function in your worker entrypoint and export the result as the
* default export. The host will import the module and call lifecycle methods
* on the returned object.
*
* @param definition - Plugin lifecycle handlers
* @returns A sealed `PaperclipPlugin` object for the host to consume
*
* @example
* ```ts
* import { definePlugin } from "@paperclipai/plugin-sdk";
*
* export default definePlugin({
* async setup(ctx) {
* ctx.logger.info("Plugin started");
* ctx.events.on("issue.created", async (event) => {
* // handle event
* });
* },
*
* async onHealth() {
* return { status: "ok" };
* },
* });
* ```
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*/
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
return Object.freeze({ definition });
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import path from "node:path";
import { startPluginDevServer } from "./dev-server.js";
function parseArg(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
if (index < 0) return undefined;
return process.argv[index + 1];
}
/**
* CLI entrypoint for the local plugin UI preview server.
*
* This is intentionally minimal and delegates all serving behavior to
* `startPluginDevServer` so tests and programmatic usage share one path.
*/
async function main() {
const rootDir = parseArg("--root") ?? process.cwd();
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
const host = parseArg("--host") ?? "127.0.0.1";
const rawPort = parseArg("--port") ?? "4177";
const port = Number.parseInt(rawPort, 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid --port value: ${rawPort}`);
}
const server = await startPluginDevServer({
rootDir: path.resolve(rootDir),
uiDir,
host,
port,
});
// eslint-disable-next-line no-console
console.log(`Paperclip plugin dev server listening at ${server.url}`);
const shutdown = async () => {
await server.close();
process.exit(0);
};
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});
}
void main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -0,0 +1,228 @@
import { createReadStream, existsSync, statSync, watch } from "node:fs";
import { mkdir, readdir, stat } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import path from "node:path";
export interface PluginDevServerOptions {
/** Plugin project root. Defaults to `process.cwd()`. */
rootDir?: string;
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
uiDir?: string;
/** Bind port for local preview server. Defaults to `4177`. */
port?: number;
/** Bind host. Defaults to `127.0.0.1`. */
host?: string;
}
export interface PluginDevServer {
url: string;
close(): Promise<void>;
}
interface Closeable {
close(): void;
}
function contentType(filePath: string): string {
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
function normalizeFilePath(baseDir: string, reqPath: string): string {
const pathname = reqPath.split("?")[0] || "/";
const resolved = pathname === "/" ? "/index.js" : pathname;
const absolute = path.resolve(baseDir, `.${resolved}`);
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
throw new Error("path traversal blocked");
}
return absolute;
}
function send404(res: ServerResponse) {
res.statusCode = 404;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: "Not found" }));
}
function sendJson(res: ServerResponse, value: unknown) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(value));
}
async function ensureUiDir(uiDir: string): Promise<void> {
if (existsSync(uiDir)) return;
await mkdir(uiDir, { recursive: true });
}
async function listFilesRecursive(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...await listFilesRecursive(abs));
} else if (entry.isFile()) {
out.push(abs);
}
}
return out;
}
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
}
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
try {
// macOS/Windows support recursive native watching.
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
if (!filename) return;
onReload(path.join(uiDir, filename));
});
return watcher;
} catch {
// Linux may reject recursive watch. Fall back to polling snapshots.
let previous = snapshotSignature(
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
file: row.file,
mtimeMs: row.mtimeMs,
})),
);
const timer = setInterval(async () => {
try {
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
const next = snapshotSignature(nextRows);
if (next === previous) return;
previous = next;
onReload("__snapshot__");
} catch {
// Ignore transient read errors while bundlers are writing files.
}
}, 500);
return {
close() {
clearInterval(timer);
},
};
}
}
/**
* Start a local static server for plugin UI assets with SSE reload events.
*
* Endpoint summary:
* - `GET /__paperclip__/health` for diagnostics
* - `GET /__paperclip__/events` for hot-reload stream
* - Any other path serves files from the configured UI build directory
*/
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
const rootDir = path.resolve(options.rootDir ?? process.cwd());
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
const host = options.host ?? "127.0.0.1";
const port = options.port ?? 4177;
await ensureUiDir(uiDir);
const sseClients = new Set<ServerResponse>();
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
const url = req.url ?? "/";
if (url === "/__paperclip__/health") {
sendJson(res, { ok: true, rootDir, uiDir });
return;
}
if (url === "/__paperclip__/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
res.write(`event: connected\ndata: {"ok":true}\n\n`);
sseClients.add(res);
req.on("close", () => {
sseClients.delete(res);
});
return;
}
try {
const filePath = normalizeFilePath(uiDir, url);
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
send404(res);
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", contentType(filePath));
createReadStream(filePath).pipe(res);
} catch {
send404(res);
}
};
const server = createServer((req, res) => {
void handleRequest(req, res);
});
const notifyReload = (filePath: string) => {
const rel = path.relative(uiDir, filePath);
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
for (const client of sseClients) {
client.write(`event: reload\ndata: ${payload}\n\n`);
}
};
const watcher = await startUiWatcher(uiDir, notifyReload);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});
const address = server.address();
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
return {
url: `http://${host}:${actualPort}`,
async close() {
watcher.close();
for (const client of sseClients) {
client.end();
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
/**
* Return a stable file+mtime snapshot for a built plugin UI directory.
*
* Used by the polling watcher fallback and useful for tests that need to assert
* whether a UI build has changed between runs.
*/
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
const baseDir = path.resolve(rootDir, uiDir);
if (!existsSync(baseDir)) return [];
const files = await listFilesRecursive(baseDir);
const rows = await Promise.all(files.map(async (filePath) => {
const fileStat = await stat(filePath);
return {
file: path.relative(baseDir, filePath),
mtimeMs: fileStat.mtimeMs,
};
}));
return rows.sort((a, b) => a.file.localeCompare(b.file));
}

View File

@@ -0,0 +1,545 @@
/**
* Host-side client factory — creates capability-gated handler maps for
* servicing worker→host JSON-RPC calls.
*
* When a plugin worker calls `ctx.state.get(...)` inside its process, the
* SDK serializes the call as a JSON-RPC request over stdio. On the host side,
* the `PluginWorkerManager` receives the request and dispatches it to the
* handler registered for that method. This module provides a factory that
* creates those handlers for all `WorkerToHostMethods`, with automatic
* capability enforcement.
*
* ## Design
*
* 1. **Capability gating**: Each handler checks the plugin's declared
* capabilities before executing. If the plugin lacks a required capability,
* the handler throws a `CapabilityDeniedError` (which the worker manager
* translates into a JSON-RPC error response with code
* `CAPABILITY_DENIED`).
*
* 2. **Service adapters**: The caller provides a `HostServices` object with
* concrete implementations of each platform service. The factory wires
* each handler to the appropriate service method.
*
* 3. **Type safety**: The returned handler map is typed as
* `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
* directly into `WorkerStartOptions.hostHandlers`.
*
* @example
* ```ts
* const handlers = createHostClientHandlers({
* pluginId: "acme.linear",
* capabilities: manifest.capabilities,
* services: {
* config: { get: () => registry.getConfig(pluginId) },
* state: { get: ..., set: ..., delete: ... },
* entities: { upsert: ..., list: ... },
* // ... all services
* },
* });
*
* await workerManager.startWorker("acme.linear", {
* // ...
* hostHandlers: handlers,
* });
* ```
*
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
* @see PLUGIN_SPEC.md §15 — Capability Model
*/
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/**
* Thrown when a plugin calls a host method it does not have the capability for.
*
* The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
* the worker manager can propagate it as the correct JSON-RPC error code.
*/
export class CapabilityDeniedError extends Error {
override readonly name = "CapabilityDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
constructor(pluginId: string, method: string, capability: PluginCapability) {
super(
`Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
);
}
}
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
/**
* Service adapters that the host must provide. Each property maps to a group
* of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
* function signatures.
*
* All methods return promises to support async I/O (database, HTTP, etc.).
*/
export interface HostServices {
/** Provides `config.get`. */
config: {
get(): Promise<Record<string, unknown>>;
};
/** Provides `state.get`, `state.set`, `state.delete`. */
state: {
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
};
/** Provides `entities.upsert`, `entities.list`. */
entities: {
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
};
/** Provides `events.emit`. */
events: {
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
};
/** Provides `http.fetch`. */
http: {
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
};
/** Provides `secrets.resolve`. */
secrets: {
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
};
/** Provides `activity.log`. */
activity: {
log(params: {
companyId: string;
message: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
}): Promise<void>;
};
/** Provides `metrics.write`. */
metrics: {
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
};
/** Provides `log`. */
logger: {
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
};
/** Provides `companies.list`, `companies.get`. */
companies: {
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
};
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
projects: {
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
};
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
issues: {
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
};
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
agents: {
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
};
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
agentSessions: {
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
};
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
goals: {
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
};
}
// ---------------------------------------------------------------------------
// Factory input
// ---------------------------------------------------------------------------
/**
* Options for `createHostClientHandlers`.
*/
export interface HostClientFactoryOptions {
/** The plugin ID. Used for error messages and logging. */
pluginId: string;
/**
* The capabilities declared by the plugin in its manifest. The factory
* enforces these at runtime before delegating to the service adapter.
*/
capabilities: readonly PluginCapability[];
/**
* Concrete implementations of host platform services. Each handler in the
* returned map delegates to the corresponding service method.
*/
services: HostServices;
}
// ---------------------------------------------------------------------------
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
// ---------------------------------------------------------------------------
/**
* A handler function for a specific worker→host method.
*/
type HostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0],
) => Promise<WorkerToHostMethods[M][1]>;
/**
* A complete map of all worker→host method handlers.
*
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
* but makes every handler required (the factory always provides all handlers).
*/
export type HostClientHandlers = {
[M in WorkerToHostMethodName]: HostHandler<M>;
};
// ---------------------------------------------------------------------------
// Capability → method mapping
// ---------------------------------------------------------------------------
/**
* Maps each worker→host RPC method to the capability required to invoke it.
* Methods without a capability requirement (e.g. `config.get`, `log`) are
* mapped to `null`.
*
* @see PLUGIN_SPEC.md §15 — Capability Model
*/
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
// Config — always allowed
"config.get": null,
// State
"state.get": "plugin.state.read",
"state.set": "plugin.state.write",
"state.delete": "plugin.state.write",
// Entities — no specific capability required (plugin-scoped by design)
"entities.upsert": null,
"entities.list": null,
// Events
"events.emit": "events.emit",
// HTTP
"http.fetch": "http.outbound",
// Secrets
"secrets.resolve": "secrets.read-ref",
// Activity
"activity.log": "activity.log.write",
// Metrics
"metrics.write": "metrics.write",
// Logger — always allowed
"log": null,
// Companies
"companies.list": "companies.read",
"companies.get": "companies.read",
// Projects
"projects.list": "projects.read",
"projects.get": "projects.read",
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
// Issues
"issues.list": "issues.read",
"issues.get": "issues.read",
"issues.create": "issues.create",
"issues.update": "issues.update",
"issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create",
// Agents
"agents.list": "agents.read",
"agents.get": "agents.read",
"agents.pause": "agents.pause",
"agents.resume": "agents.resume",
"agents.invoke": "agents.invoke",
// Agent Sessions
"agents.sessions.create": "agent.sessions.create",
"agents.sessions.list": "agent.sessions.list",
"agents.sessions.sendMessage": "agent.sessions.send",
"agents.sessions.close": "agent.sessions.close",
// Goals
"goals.list": "goals.read",
"goals.get": "goals.read",
"goals.create": "goals.create",
"goals.update": "goals.update",
};
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a complete handler map for all worker→host JSON-RPC methods.
*
* Each handler:
* 1. Checks the plugin's declared capabilities against the required capability
* for the method (if any).
* 2. Delegates to the corresponding service adapter method.
* 3. Returns the service result, which is serialized as the JSON-RPC response
* by the worker manager.
*
* If a capability check fails, the handler throws a `CapabilityDeniedError`
* with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
* JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
* in the plugin's SDK client.
*
* @param options - Plugin ID, capabilities, and service adapters
* @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
*/
export function createHostClientHandlers(
options: HostClientFactoryOptions,
): HostClientHandlers {
const { pluginId, services } = options;
const capabilitySet = new Set<PluginCapability>(options.capabilities);
/**
* Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing.
*/
function requireCapability(
method: WorkerToHostMethodName,
): void {
const required = METHOD_CAPABILITY_MAP[method];
if (required === null) return; // No capability required
if (capabilitySet.has(required)) return;
throw new CapabilityDeniedError(pluginId, method, required);
}
/**
* Create a capability-gated proxy handler for a method.
*
* @param method - The RPC method name (used for capability lookup)
* @param handler - The actual handler implementation
* @returns A wrapper that checks capabilities before delegating
*/
function gated<M extends WorkerToHostMethodName>(
method: M,
handler: HostHandler<M>,
): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => {
requireCapability(method);
return handler(params);
};
}
// -------------------------------------------------------------------------
// Build the complete handler map
// -------------------------------------------------------------------------
return {
// Config
"config.get": gated("config.get", async () => {
return services.config.get();
}),
// State
"state.get": gated("state.get", async (params) => {
return services.state.get(params);
}),
"state.set": gated("state.set", async (params) => {
return services.state.set(params);
}),
"state.delete": gated("state.delete", async (params) => {
return services.state.delete(params);
}),
// Entities
"entities.upsert": gated("entities.upsert", async (params) => {
return services.entities.upsert(params);
}),
"entities.list": gated("entities.list", async (params) => {
return services.entities.list(params);
}),
// Events
"events.emit": gated("events.emit", async (params) => {
return services.events.emit(params);
}),
// HTTP
"http.fetch": gated("http.fetch", async (params) => {
return services.http.fetch(params);
}),
// Secrets
"secrets.resolve": gated("secrets.resolve", async (params) => {
return services.secrets.resolve(params);
}),
// Activity
"activity.log": gated("activity.log", async (params) => {
return services.activity.log(params);
}),
// Metrics
"metrics.write": gated("metrics.write", async (params) => {
return services.metrics.write(params);
}),
// Logger
"log": gated("log", async (params) => {
return services.logger.log(params);
}),
// Companies
"companies.list": gated("companies.list", async (params) => {
return services.companies.list(params);
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
}),
// Projects
"projects.list": gated("projects.list", async (params) => {
return services.projects.list(params);
}),
"projects.get": gated("projects.get", async (params) => {
return services.projects.get(params);
}),
"projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
return services.projects.listWorkspaces(params);
}),
"projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
return services.projects.getPrimaryWorkspace(params);
}),
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
return services.projects.getWorkspaceForIssue(params);
}),
// Issues
"issues.list": gated("issues.list", async (params) => {
return services.issues.list(params);
}),
"issues.get": gated("issues.get", async (params) => {
return services.issues.get(params);
}),
"issues.create": gated("issues.create", async (params) => {
return services.issues.create(params);
}),
"issues.update": gated("issues.update", async (params) => {
return services.issues.update(params);
}),
"issues.listComments": gated("issues.listComments", async (params) => {
return services.issues.listComments(params);
}),
"issues.createComment": gated("issues.createComment", async (params) => {
return services.issues.createComment(params);
}),
// Agents
"agents.list": gated("agents.list", async (params) => {
return services.agents.list(params);
}),
"agents.get": gated("agents.get", async (params) => {
return services.agents.get(params);
}),
"agents.pause": gated("agents.pause", async (params) => {
return services.agents.pause(params);
}),
"agents.resume": gated("agents.resume", async (params) => {
return services.agents.resume(params);
}),
"agents.invoke": gated("agents.invoke", async (params) => {
return services.agents.invoke(params);
}),
// Agent Sessions
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
return services.agentSessions.create(params);
}),
"agents.sessions.list": gated("agents.sessions.list", async (params) => {
return services.agentSessions.list(params);
}),
"agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
return services.agentSessions.sendMessage(params);
}),
"agents.sessions.close": gated("agents.sessions.close", async (params) => {
return services.agentSessions.close(params);
}),
// Goals
"goals.list": gated("goals.list", async (params) => {
return services.goals.list(params);
}),
"goals.get": gated("goals.get", async (params) => {
return services.goals.get(params);
}),
"goals.create": gated("goals.create", async (params) => {
return services.goals.create(params);
}),
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
};
}
// ---------------------------------------------------------------------------
// Utility: getRequiredCapability
// ---------------------------------------------------------------------------
/**
* Get the capability required for a given worker→host method, or `null` if
* no capability is required.
*
* Useful for inspecting capability requirements without calling the factory.
*
* @param method - The worker→host method name
* @returns The required capability, or `null`
*/
export function getRequiredCapability(
method: WorkerToHostMethodName,
): PluginCapability | null {
return METHOD_CAPABILITY_MAP[method];
}

View File

@@ -0,0 +1,286 @@
/**
* `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
*
* This is the main entrypoint for plugin worker code. For plugin UI bundles,
* import from `@paperclipai/plugin-sdk/ui` instead.
*
* @example
* ```ts
* // Plugin worker entrypoint (dist/worker.ts)
* import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
*
* const plugin = definePlugin({
* async setup(ctx) {
* ctx.logger.info("Plugin starting up");
*
* ctx.events.on("issue.created", async (event) => {
* ctx.logger.info("Issue created", { issueId: event.entityId });
* });
*
* ctx.jobs.register("full-sync", async (job) => {
* ctx.logger.info("Starting full sync", { runId: job.runId });
* // ... sync implementation
* });
*
* ctx.data.register("sync-health", async ({ companyId }) => {
* const state = await ctx.state.get({
* scopeKind: "company",
* scopeId: String(companyId),
* stateKey: "last-sync-at",
* });
* return { lastSync: state };
* });
* },
*
* async onHealth() {
* return { status: "ok" };
* },
* });
*
* export default plugin;
* runWorker(plugin, import.meta.url);
* ```
*
* @see PLUGIN_SPEC.md §14 — SDK Surface
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
*/
// ---------------------------------------------------------------------------
// Main factory
// ---------------------------------------------------------------------------
export { definePlugin } from "./define-plugin.js";
export { createTestHarness } from "./testing.js";
export { createPluginBundlerPresets } from "./bundlers.js";
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
export {
JSONRPC_VERSION,
JSONRPC_ERROR_CODES,
PLUGIN_RPC_ERROR_CODES,
HOST_TO_WORKER_REQUIRED_METHODS,
HOST_TO_WORKER_OPTIONAL_METHODS,
MESSAGE_DELIMITER,
createRequest,
createSuccessResponse,
createErrorResponse,
createNotification,
isJsonRpcRequest,
isJsonRpcNotification,
isJsonRpcResponse,
isJsonRpcSuccessResponse,
isJsonRpcErrorResponse,
serializeMessage,
parseMessage,
JsonRpcParseError,
JsonRpcCallError,
_resetIdCounter,
} from "./protocol.js";
// ---------------------------------------------------------------------------
// Type exports
// ---------------------------------------------------------------------------
// Plugin definition and lifecycle types
export type {
PluginDefinition,
PaperclipPlugin,
PluginHealthDiagnostics,
PluginConfigValidationResult,
PluginWebhookInput,
} from "./define-plugin.js";
export type {
TestHarness,
TestHarnessOptions,
TestHarnessLogEntry,
} from "./testing.js";
export type {
PluginBundlerPresetInput,
PluginBundlerPresets,
EsbuildLikeOptions,
RollupLikeConfig,
} from "./bundlers.js";
export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
export type {
WorkerRpcHostOptions,
WorkerRpcHost,
RunWorkerOptions,
} from "./worker-rpc-host.js";
export type {
HostServices,
HostClientFactoryOptions,
HostClientHandlers,
} from "./host-client-factory.js";
// JSON-RPC protocol types
export type {
JsonRpcId,
JsonRpcRequest,
JsonRpcSuccessResponse,
JsonRpcError,
JsonRpcErrorResponse,
JsonRpcResponse,
JsonRpcNotification,
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
InitializeParams,
InitializeResult,
ConfigChangedParams,
ValidateConfigParams,
OnEventParams,
RunJobParams,
GetDataParams,
PerformActionParams,
ExecuteToolParams,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
PluginLauncherRenderContextSnapshot,
HostToWorkerMethods,
HostToWorkerMethodName,
WorkerToHostMethods,
WorkerToHostMethodName,
HostToWorkerRequest,
HostToWorkerResponse,
WorkerToHostRequest,
WorkerToHostResponse,
WorkerToHostNotifications,
WorkerToHostNotificationName,
} from "./protocol.js";
// Plugin context and all client interfaces
export type {
PluginContext,
PluginConfigClient,
PluginEventsClient,
PluginJobsClient,
PluginLaunchersClient,
PluginHttpClient,
PluginSecretsClient,
PluginActivityClient,
PluginActivityLogEntry,
PluginStateClient,
PluginEntitiesClient,
PluginProjectsClient,
PluginCompaniesClient,
PluginIssuesClient,
PluginAgentsClient,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
AgentSessionSendResult,
PluginGoalsClient,
PluginDataClient,
PluginActionsClient,
PluginStreamsClient,
PluginToolsClient,
PluginMetricsClient,
PluginLogger,
} from "./types.js";
// Supporting types for context clients
export type {
ScopeKey,
EventFilter,
PluginEvent,
PluginJobContext,
PluginLauncherRegistration,
ToolRunContext,
ToolResult,
PluginEntityUpsert,
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
Company,
Project,
Issue,
IssueComment,
Agent,
Goal,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
// Plugin authors import manifest types from here so they have a single
// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
export type {
PaperclipPluginManifestV1,
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginRecord,
PluginConfig,
JsonSchema,
PluginStatus,
PluginCategory,
PluginCapability,
PluginUiSlotType,
PluginUiSlotEntityType,
PluginLauncherPlacementZone,
PluginLauncherAction,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
PluginStateScopeKind,
PluginJobStatus,
PluginJobRunStatus,
PluginJobRunTrigger,
PluginWebhookDeliveryStatus,
PluginEventType,
PluginBridgeErrorCode,
} from "./types.js";
// ---------------------------------------------------------------------------
// Zod re-export
// ---------------------------------------------------------------------------
/**
* Zod is re-exported for plugin authors to use when defining their
* `instanceConfigSchema` and tool `parametersSchema`.
*
* Plugin authors do not need to add a separate `zod` dependency.
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*
* @example
* ```ts
* import { z } from "@paperclipai/plugin-sdk";
*
* const configSchema = z.object({
* apiKey: z.string().describe("Your API key"),
* workspace: z.string().optional(),
* });
* ```
*/
export { z } from "zod";
// ---------------------------------------------------------------------------
// Constants re-exports (for plugin code that needs to check values at runtime)
// ---------------------------------------------------------------------------
export {
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
PLUGIN_JOB_RUN_TRIGGERS,
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
} from "@paperclipai/shared";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,705 @@
import { randomUUID } from "node:crypto";
import type {
PaperclipPluginManifestV1,
PluginCapability,
PluginEventType,
Company,
Project,
Issue,
IssueComment,
Agent,
Goal,
} from "@paperclipai/shared";
import type {
EventFilter,
PluginContext,
PluginEntityRecord,
PluginEntityUpsert,
PluginJobContext,
PluginLauncherRegistration,
PluginEvent,
ScopeKey,
ToolResult,
ToolRunContext,
PluginWorkspace,
AgentSession,
AgentSessionEvent,
} from "./types.js";
export interface TestHarnessOptions {
/** Plugin manifest used to seed capability checks and metadata. */
manifest: PaperclipPluginManifestV1;
/** Optional capability override. Defaults to `manifest.capabilities`. */
capabilities?: PluginCapability[];
/** Initial config returned by `ctx.config.get()`. */
config?: Record<string, unknown>;
}
export interface TestHarnessLogEntry {
level: "info" | "warn" | "error" | "debug";
message: string;
meta?: Record<string, unknown>;
}
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
issues?: Issue[];
issueComments?: IssueComment[];
agents?: Agent[];
goals?: Goal[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
/** Execute a previously-registered scheduled job handler. */
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
/** Invoke a `ctx.data.register(...)` handler by key. */
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
/** Invoke a `ctx.actions.register(...)` handler by key. */
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
/** Read raw in-memory state for assertions. */
getState(input: ScopeKey): unknown;
/** Simulate a streaming event arriving for an active session. */
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
logs: TestHarnessLogEntry[];
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
}
type EventRegistration = {
name: PluginEventType | `plugin.${string}`;
filter?: EventFilter;
fn: (event: PluginEvent) => Promise<void>;
};
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
return {
scopeKind: input.scopeKind,
scopeId: input.scopeId,
namespace: input.namespace ?? "default",
stateKey: input.stateKey,
};
}
function stateMapKey(input: ScopeKey): string {
const normalized = normalizeScope(input);
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
}
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
if (!filter) return true;
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
return true;
}
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
if (allowed.has(capability)) return;
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
}
function requireCompanyId(companyId?: string): string {
if (!companyId) throw new Error("companyId is required for this operation");
return companyId;
}
function isInCompany<T extends { companyId: string | null | undefined }>(
record: T | null | undefined,
companyId: string,
): record is T {
return Boolean(record && record.companyId === companyId);
}
/**
* Create an in-memory host harness for plugin worker tests.
*
* The harness enforces declared capabilities and simulates host APIs, so tests
* can validate plugin behavior without spinning up the Paperclip server runtime.
*/
export function createTestHarness(options: TestHarnessOptions): TestHarness {
const manifest = options.manifest;
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
let currentConfig = { ...(options.config ?? {}) };
const logs: TestHarnessLogEntry[] = [];
const activity: TestHarness["activity"] = [];
const metrics: TestHarness["metrics"] = [];
const state = new Map<string, unknown>();
const entities = new Map<string, PluginEntityRecord>();
const entityExternalIndex = new Map<string, string>();
const companies = new Map<string, Company>();
const projects = new Map<string, Project>();
const issues = new Map<string, Issue>();
const issueComments = new Map<string, IssueComment[]>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const sessions = new Map<string, AgentSession>();
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
const events: EventRegistration[] = [];
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launchers = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
const ctx: PluginContext = {
manifest,
config: {
async get() {
return { ...currentConfig };
},
},
events: {
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
requireCapability(manifest, capabilitySet, "events.subscribe");
let registration: EventRegistration;
if (typeof filterOrFn === "function") {
registration = { name, fn: filterOrFn };
} else {
if (!maybeFn) throw new Error("event handler is required");
registration = { name, filter: filterOrFn, fn: maybeFn };
}
events.push(registration);
return () => {
const idx = events.indexOf(registration);
if (idx !== -1) events.splice(idx, 1);
};
},
async emit(name, companyId, payload) {
requireCapability(manifest, capabilitySet, "events.emit");
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
},
},
jobs: {
register(key, fn) {
requireCapability(manifest, capabilitySet, "jobs.schedule");
jobs.set(key, fn);
},
},
launchers: {
register(launcher) {
launchers.set(launcher.id, launcher);
},
},
http: {
async fetch(url, init) {
requireCapability(manifest, capabilitySet, "http.outbound");
return fetch(url, init);
},
},
secrets: {
async resolve(secretRef) {
requireCapability(manifest, capabilitySet, "secrets.read-ref");
return `resolved:${secretRef}`;
},
},
activity: {
async log(entry) {
requireCapability(manifest, capabilitySet, "activity.log.write");
activity.push(entry);
},
},
state: {
async get(input) {
requireCapability(manifest, capabilitySet, "plugin.state.read");
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
},
async set(input, value) {
requireCapability(manifest, capabilitySet, "plugin.state.write");
state.set(stateMapKey(input), value);
},
async delete(input) {
requireCapability(manifest, capabilitySet, "plugin.state.write");
state.delete(stateMapKey(input));
},
},
entities: {
async upsert(input: PluginEntityUpsert) {
const externalKey = input.externalId
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
: null;
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
const existing = existingId ? entities.get(existingId) : undefined;
const now = new Date().toISOString();
const previousExternalKey = existing?.externalId
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
: null;
const record: PluginEntityRecord = existing
? {
...existing,
entityType: input.entityType,
scopeKind: input.scopeKind,
scopeId: input.scopeId ?? null,
externalId: input.externalId ?? null,
title: input.title ?? null,
status: input.status ?? null,
data: input.data,
updatedAt: now,
}
: {
id: randomUUID(),
entityType: input.entityType,
scopeKind: input.scopeKind,
scopeId: input.scopeId ?? null,
externalId: input.externalId ?? null,
title: input.title ?? null,
status: input.status ?? null,
data: input.data,
createdAt: now,
updatedAt: now,
};
entities.set(record.id, record);
if (previousExternalKey && previousExternalKey !== externalKey) {
entityExternalIndex.delete(previousExternalKey);
}
if (externalKey) entityExternalIndex.set(externalKey, record.id);
return record;
},
async list(query) {
let out = [...entities.values()];
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
if (query.offset) out = out.slice(query.offset);
if (query.limit) out = out.slice(0, query.limit);
return out;
},
},
projects: {
async list(input) {
requireCapability(manifest, capabilitySet, "projects.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...projects.values()];
out = out.filter((project) => project.companyId === companyId);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(projectId, companyId) {
requireCapability(manifest, capabilitySet, "projects.read");
const project = projects.get(projectId);
return isInCompany(project, companyId) ? project : null;
},
async listWorkspaces(projectId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
if (!isInCompany(projects.get(projectId), companyId)) return [];
return projectWorkspaces.get(projectId) ?? [];
},
async getPrimaryWorkspace(projectId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
if (!isInCompany(projects.get(projectId), companyId)) return null;
const workspaces = projectWorkspaces.get(projectId) ?? [];
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
},
async getWorkspaceForIssue(issueId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
const issue = issues.get(issueId);
if (!isInCompany(issue, companyId)) return null;
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
if (!projectId) return null;
if (!isInCompany(projects.get(projectId), companyId)) return null;
const workspaces = projectWorkspaces.get(projectId) ?? [];
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
},
},
companies: {
async list(input) {
requireCapability(manifest, capabilitySet, "companies.read");
let out = [...companies.values()];
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(companyId) {
requireCapability(manifest, capabilitySet, "companies.read");
return companies.get(companyId) ?? null;
},
},
issues: {
async list(input) {
requireCapability(manifest, capabilitySet, "issues.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...issues.values()];
out = out.filter((issue) => issue.companyId === companyId);
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
if (input?.status) out = out.filter((issue) => issue.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issues.read");
const issue = issues.get(issueId);
return isInCompany(issue, companyId) ? issue : null;
},
async create(input) {
requireCapability(manifest, capabilitySet, "issues.create");
const now = new Date();
const record: Issue = {
id: randomUUID(),
companyId: input.companyId,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
parentId: input.parentId ?? null,
title: input.title,
description: input.description ?? null,
status: "todo",
priority: input.priority ?? "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: null,
identifier: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: now,
updatedAt: now,
};
issues.set(record.id, record);
return record;
},
async update(issueId, patch, companyId) {
requireCapability(manifest, capabilitySet, "issues.update");
const record = issues.get(issueId);
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
const updated: Issue = {
...record,
...patch,
updatedAt: new Date(),
};
issues.set(issueId, updated);
return updated;
},
async listComments(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.comments.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
return issueComments.get(issueId) ?? [];
},
async createComment(issueId, body, companyId) {
requireCapability(manifest, capabilitySet, "issue.comments.create");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
const now = new Date();
const comment: IssueComment = {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorAgentId: null,
authorUserId: null,
body,
createdAt: now,
updatedAt: now,
};
const current = issueComments.get(issueId) ?? [];
current.push(comment);
issueComments.set(issueId, current);
return comment;
},
},
agents: {
async list(input) {
requireCapability(manifest, capabilitySet, "agents.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...agents.values()];
out = out.filter((agent) => agent.companyId === companyId);
if (input?.status) out = out.filter((agent) => agent.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.read");
const agent = agents.get(agentId);
return isInCompany(agent, companyId) ? agent : null;
},
async pause(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.pause");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
agents.set(agentId, updated);
return updated;
},
async resume(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.resume");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
agents.set(agentId, updated);
return updated;
},
async invoke(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agents.invoke");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (
agent!.status === "paused" ||
agent!.status === "terminated" ||
agent!.status === "pending_approval"
) {
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
}
return { runId: randomUUID() };
},
sessions: {
async create(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.create");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
const session: AgentSession = {
sessionId: randomUUID(),
agentId,
companyId: cid,
status: "active",
createdAt: new Date().toISOString(),
};
sessions.set(session.sessionId, session);
return session;
},
async list(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agent.sessions.list");
const cid = requireCompanyId(companyId);
return [...sessions.values()].filter(
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
);
},
async sendMessage(sessionId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.send");
const session = sessions.get(sessionId);
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
if (opts.onEvent) {
sessionEventCallbacks.set(sessionId, opts.onEvent);
}
return { runId: randomUUID() };
},
async close(sessionId, companyId) {
requireCapability(manifest, capabilitySet, "agent.sessions.close");
const session = sessions.get(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
session.status = "closed";
sessionEventCallbacks.delete(sessionId);
},
},
},
goals: {
async list(input) {
requireCapability(manifest, capabilitySet, "goals.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...goals.values()];
out = out.filter((goal) => goal.companyId === companyId);
if (input?.level) out = out.filter((goal) => goal.level === input.level);
if (input?.status) out = out.filter((goal) => goal.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(goalId, companyId) {
requireCapability(manifest, capabilitySet, "goals.read");
const goal = goals.get(goalId);
return isInCompany(goal, companyId) ? goal : null;
},
async create(input) {
requireCapability(manifest, capabilitySet, "goals.create");
const now = new Date();
const record: Goal = {
id: randomUUID(),
companyId: input.companyId,
title: input.title,
description: input.description ?? null,
level: input.level ?? "task",
status: input.status ?? "planned",
parentId: input.parentId ?? null,
ownerAgentId: input.ownerAgentId ?? null,
createdAt: now,
updatedAt: now,
};
goals.set(record.id, record);
return record;
},
async update(goalId, patch, companyId) {
requireCapability(manifest, capabilitySet, "goals.update");
const record = goals.get(goalId);
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
const updated: Goal = {
...record,
...patch,
updatedAt: new Date(),
};
goals.set(goalId, updated);
return updated;
},
},
data: {
register(key, handler) {
dataHandlers.set(key, handler);
},
},
actions: {
register(key, handler) {
actionHandlers.set(key, handler);
},
},
streams: (() => {
const channelCompanyMap = new Map<string, string>();
return {
open(channel: string, companyId: string) {
channelCompanyMap.set(channel, companyId);
},
emit(_channel: string, _event: unknown) {
// No-op in test harness — events are not forwarded
},
close(channel: string) {
channelCompanyMap.delete(channel);
},
};
})(),
tools: {
register(name, _decl, fn) {
requireCapability(manifest, capabilitySet, "agent.tools.register");
toolHandlers.set(name, fn);
},
},
metrics: {
async write(name, value, tags) {
requireCapability(manifest, capabilitySet, "metrics.write");
metrics.push({ name, value, tags });
},
},
logger: {
info(message, meta) {
logs.push({ level: "info", message, meta });
},
warn(message, meta) {
logs.push({ level: "warn", message, meta });
},
error(message, meta) {
logs.push({ level: "error", message, meta });
},
debug(message, meta) {
logs.push({ level: "debug", message, meta });
},
},
};
const harness: TestHarness = {
ctx,
seed(input) {
for (const row of input.companies ?? []) companies.set(row.id, row);
for (const row of input.projects ?? []) projects.set(row.id, row);
for (const row of input.issues ?? []) issues.set(row.id, row);
for (const row of input.issueComments ?? []) {
const list = issueComments.get(row.issueId) ?? [];
list.push(row);
issueComments.set(row.issueId, list);
}
for (const row of input.agents ?? []) agents.set(row.id, row);
for (const row of input.goals ?? []) goals.set(row.id, row);
},
setConfig(config) {
currentConfig = { ...config };
},
async emit(eventType, payload, base) {
const event: PluginEvent = {
eventId: base?.eventId ?? randomUUID(),
eventType,
companyId: base?.companyId ?? "test-company",
occurredAt: base?.occurredAt ?? new Date().toISOString(),
actorId: base?.actorId,
actorType: base?.actorType,
entityId: base?.entityId,
entityType: base?.entityType,
payload,
};
for (const handler of events) {
const exactMatch = handler.name === event.eventType;
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
const wildcardPluginOne = String(handler.name).endsWith(".*")
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
if (!allowsEvent(handler.filter, event)) continue;
await handler.fn(event);
}
},
async runJob(jobKey, partial = {}) {
const handler = jobs.get(jobKey);
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
await handler({
jobKey,
runId: partial.runId ?? randomUUID(),
trigger: partial.trigger ?? "manual",
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
});
},
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
const handler = dataHandlers.get(key);
if (!handler) throw new Error(`No data handler registered for '${key}'`);
return await handler(params) as T;
},
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
const handler = actionHandlers.get(key);
if (!handler) throw new Error(`No action handler registered for '${key}'`);
return await handler(params) as T;
},
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
const handler = toolHandlers.get(name);
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
const ctxToPass: ToolRunContext = {
agentId: runCtx.agentId ?? "agent-test",
runId: runCtx.runId ?? randomUUID(),
companyId: runCtx.companyId ?? "company-test",
projectId: runCtx.projectId ?? "project-test",
};
return await handler(params, ctxToPass) as T;
},
getState(input) {
return state.get(stateMapKey(input));
},
simulateSessionEvent(sessionId, event) {
const cb = sessionEventCallbacks.get(sessionId);
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
cb({ ...event, sessionId });
},
logs,
activity,
metrics,
};
return harness;
}

Some files were not shown because too many files have changed in this diff Show More