From 0d4dd50b350927e401a4c47281a9be9cbeb2118e Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Mon, 16 Mar 2026 15:53:50 -0600 Subject: [PATCH 01/19] feat(plugins): add document CRUD methods to Plugin SDK Wire issue document list/get/upsert/delete operations through the JSON-RPC protocol so plugins can manage issue documents with the same capabilities available via the REST API. Fixes #940 --- .../plugins/sdk/src/host-client-factory.ts | 28 +++++++ packages/plugins/sdk/src/protocol.ts | 28 +++++++ packages/plugins/sdk/src/testing.ts | 17 ++++ packages/plugins/sdk/src/types.ts | 77 ++++++++++++++++++- packages/plugins/sdk/src/worker-rpc-host.ts | 26 +++++++ packages/shared/src/constants.ts | 2 + server/src/services/plugin-host-services.ts | 39 ++++++++++ 7 files changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index e445cc0b..c976fe8f 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -165,6 +165,14 @@ export interface HostServices { createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; }; + /** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */ + issueDocuments: { + list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise; + get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise; + upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise; + delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise; + }; + /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ agents: { list(params: WorkerToHostMethods["agents.list"][0]): Promise; @@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record { + return services.issueDocuments.list(params); + }), + "issues.documents.get": gated("issues.documents.get", async (params) => { + return services.issueDocuments.get(params); + }), + "issues.documents.upsert": gated("issues.documents.upsert", async (params) => { + return services.issueDocuments.upsert(params); + }), + "issues.documents.delete": gated("issues.documents.delete", async (params) => { + return services.issueDocuments.delete(params); + }), + // Agents "agents.list": gated("agents.list", async (params) => { return services.agents.list(params); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 61228b53..8a99bcba 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -25,6 +25,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -601,6 +603,32 @@ export interface WorkerToHostMethods { result: IssueComment, ]; + // Issue Documents + "issues.documents.list": [ + params: { issueId: string; companyId: string }, + result: IssueDocumentSummary[], + ]; + "issues.documents.get": [ + params: { issueId: string; key: string; companyId: string }, + result: IssueDocument | null, + ]; + "issues.documents.upsert": [ + params: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }, + result: IssueDocument, + ]; + "issues.documents.delete": [ + params: { issueId: string; key: string; companyId: string }, + result: void, + ]; + // Agents (read) "agents.list": [ params: { companyId: string; status?: string; limit?: number; offset?: number }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 48705b4d..2bb82c19 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -422,6 +422,23 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { issueComments.set(issueId, current); return comment; }, + documents: { + async list(_issueId, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + return []; + }, + async get(_issueId, _key, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + return null; + }, + async upsert(_input) { + requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + throw new Error("documents.upsert is not implemented in test context"); + }, + async delete(_issueId, _key, _companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + }, + }, }, agents: { async list(input) { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 0ea2db2e..06046983 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -19,6 +19,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -61,6 +63,8 @@ export type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -774,6 +778,73 @@ export interface PluginCompaniesClient { get(companyId: string): Promise; } +/** + * `ctx.issues.documents` — read and write issue documents. + * + * Requires: + * - `issue.documents.read` for `list` and `get` + * - `issue.documents.write` for `upsert` and `delete` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ +export interface PluginIssueDocumentsClient { + /** + * List all documents attached to an issue. + * + * Returns summary metadata (id, key, title, format, timestamps) without + * the full document body. Use `get()` to fetch a specific document's body. + * + * Requires the `issue.documents.read` capability. + */ + list(issueId: string, companyId: string): Promise; + + /** + * Get a single document by key, including its full body content. + * + * Returns `null` if no document exists with the given key. + * + * Requires the `issue.documents.read` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key (e.g. `"plan"`, `"design-spec"`) + * @param companyId - UUID of the company + */ + get(issueId: string, key: string, companyId: string): Promise; + + /** + * Create or update a document on an issue. + * + * If a document with the given key already exists, it is updated and a new + * revision is created. If it does not exist, it is created. + * + * Requires the `issue.documents.write` capability. + * + * @param input - Document data including issueId, key, body, and optional title/format/changeSummary + */ + upsert(input: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }): Promise; + + /** + * Delete a document and all its revisions. + * + * No-ops silently if the document does not exist (idempotent). + * + * Requires the `issue.documents.write` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key to delete + * @param companyId - UUID of the company + */ + delete(issueId: string, key: string, companyId: string): Promise; +} + /** * `ctx.issues` — read and mutate issues plus comments. * @@ -783,6 +854,8 @@ export interface PluginCompaniesClient { * - `issues.update` for update * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.documents.read` for `documents.list` and `documents.get` + * - `issue.documents.write` for `documents.upsert` and `documents.delete` */ export interface PluginIssuesClient { list(input: { @@ -814,6 +887,8 @@ export interface PluginIssuesClient { ): Promise; listComments(issueId: string, companyId: string): Promise; createComment(issueId: string, body: string, companyId: string): Promise; + /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ + documents: PluginIssueDocumentsClient; } /** @@ -1056,7 +1131,7 @@ export interface PluginContext { /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; - /** Read and write issues/comments. Requires issue capabilities. */ + /** Read and write issues, comments, and documents. Requires issue capabilities. */ issues: PluginIssuesClient; /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 1e8d5591..df387490 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost async createComment(issueId: string, body: string, companyId: string) { return callHost("issues.createComment", { issueId, body, companyId }); }, + + documents: { + async list(issueId: string, companyId: string) { + return callHost("issues.documents.list", { issueId, companyId }); + }, + + async get(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.get", { issueId, key, companyId }); + }, + + async upsert(input) { + return callHost("issues.documents.upsert", { + issueId: input.issueId, + key: input.key, + body: input.body, + companyId: input.companyId, + title: input.title, + format: input.format, + changeSummary: input.changeSummary, + }); + }, + + async delete(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.delete", { issueId, key, companyId }); + }, + }, }, agents: { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cb3986f9..541a5ea6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -315,6 +315,7 @@ export const PLUGIN_CAPABILITIES = [ "project.workspaces.read", "issues.read", "issue.comments.read", + "issue.documents.read", "agents.read", "goals.read", "goals.create", @@ -325,6 +326,7 @@ export const PLUGIN_CAPABILITIES = [ "issues.create", "issues.update", "issue.comments.create", + "issue.documents.write", "agents.pause", "agents.resume", "agents.invoke", diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 2a3f5ecc..e007c1b8 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -16,6 +16,7 @@ import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; import { goalService } from "./goals.js"; +import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { randomUUID } from "node:crypto"; @@ -450,6 +451,7 @@ export function buildHostServices( const heartbeat = heartbeatService(db); const projects = projectService(db); const issues = issueService(db); + const documents = documentService(db); const goals = goalService(db); const activity = activityService(db); const costs = costService(db); @@ -796,6 +798,43 @@ export function buildHostServices( }, }, + issueDocuments: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const rows = await documents.listIssueDocuments(params.issueId); + return rows as any; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const doc = await documents.getIssueDocumentByKey(params.issueId, params.key); + return (doc ?? null) as any; + }, + async upsert(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const result = await documents.upsertIssueDocument({ + issueId: params.issueId, + key: params.key, + body: params.body, + title: params.title ?? null, + format: params.format ?? "markdown", + changeSummary: params.changeSummary ?? null, + }); + return result.document as any; + }, + async delete(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + await documents.deleteIssueDocument(params.issueId, params.key); + }, + }, + agents: { async list(params) { const companyId = ensureCompanyId(params.companyId); From 56985a320f9670210a66b36224b85551d9010cac Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Mon, 16 Mar 2026 16:01:00 -0600 Subject: [PATCH 02/19] fix(plugins): address Greptile feedback on testing.ts Remove unnecessary `as any` casts on capability strings (now valid PluginCapability members) and add company-membership guards to match production behavior in plugin-host-services.ts. --- packages/plugins/sdk/src/testing.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 2bb82c19..5c02bf7c 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -423,20 +423,30 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return comment; }, documents: { - async list(_issueId, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + async list(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; return []; }, - async get(_issueId, _key, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.read" as any); + async get(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return null; return null; }, - async upsert(_input) { - requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + async upsert(input) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(input.issueId); + if (!isInCompany(parentIssue, input.companyId)) { + throw new Error(`Issue not found: ${input.issueId}`); + } throw new Error("documents.upsert is not implemented in test context"); }, - async delete(_issueId, _key, _companyId) { - requireCapability(manifest, capabilitySet, "issue.documents.write" as any); + async delete(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } }, }, }, From 02bf0dd8622d596c45aa51c0a616952e92237e08 Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Tue, 17 Mar 2026 09:33:23 +0530 Subject: [PATCH 03/19] add app version label --- server/src/__tests__/health.test.ts | 3 ++- server/src/routes/health.ts | 4 +++- server/src/version.ts | 10 ++++++++++ ui/src/api/health.ts | 1 + ui/src/components/Layout.tsx | 6 ++++++ 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 server/src/version.ts diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 5583955f..1511b95e 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import express from "express"; import request from "supertest"; import { healthRoutes } from "../routes/health.js"; +import { serverVersion } from "../version.js"; describe("GET /health", () => { const app = express(); @@ -10,6 +11,6 @@ describe("GET /health", () => { it("returns 200 with status ok", async () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok" }); + expect(res.body).toEqual({ status: "ok", version: serverVersion }); }); }); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index ddc7c441..59897a89 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import { serverVersion } from "../version.js"; export function healthRoutes( db?: Db, @@ -22,7 +23,7 @@ export function healthRoutes( router.get("/", async (_req, res) => { if (!db) { - res.json({ status: "ok" }); + res.json({ status: "ok", version: serverVersion }); return; } @@ -56,6 +57,7 @@ export function healthRoutes( res.json({ status: "ok", + version: serverVersion, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, diff --git a/server/src/version.ts b/server/src/version.ts new file mode 100644 index 00000000..39a16a4c --- /dev/null +++ b/server/src/version.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module"; + +type PackageJson = { + version?: string; +}; + +const require = createRequire(import.meta.url); +const pkg = require("../package.json") as PackageJson; + +export const serverVersion = pkg.version ?? "0.0.0"; diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index cb1b1374..b1573805 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,5 +1,6 @@ export type HealthStatus = { status: "ok"; + version?: string; deploymentMode?: "local_trusted" | "authenticated"; deploymentExposure?: "private" | "public"; authReady?: boolean; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 43094b51..5c8f43d6 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -268,6 +268,9 @@ export function Layout() { Documentation + + {health?.version ? `v${health.version}` : "v?.?.?"} + + ) : confirming ? ( +
+ + {action} “{project.name}”? + + + +
+ ) : ( + + )} + + ); +} + export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -962,34 +1027,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Danger Zone
-
-

- {project.archivedAt - ? "Unarchive this project to restore it in the sidebar and project selectors." - : "Archive this project to hide it from the sidebar and project selectors."} -

- -
+ )} From d06cbb84f4af082f71b8e6abe5d3ba21db0883fd Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 12:33:20 -0500 Subject: [PATCH 11/19] fix: increase top margin on markdown headers for better visual separation Headers were butting up against previous paragraphs too closely. Changed rendered markdown header selectors from :where() to direct element selectors to increase CSS specificity and beat Tailwind prose defaults. Bumped margin-top from 1.15rem to 1.75rem. Also added top margins to MDXEditor headers (h1: 1.4em, h2: 1.3em, h3: 1.2em) which previously had none. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/index.css | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ui/src/index.css b/ui/src/index.css index c9ee652f..6a821296 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -377,21 +377,21 @@ } .paperclip-mdxeditor-content h1 { - margin: 0 0 0.9em; + margin: 1.4em 0 0.9em; font-size: 1.75em; font-weight: 700; line-height: 1.2; } .paperclip-mdxeditor-content h2 { - margin: 0 0 0.85em; + margin: 1.3em 0 0.85em; font-size: 1.35em; font-weight: 700; line-height: 1.3; } .paperclip-mdxeditor-content h3 { - margin: 0 0 0.8em; + margin: 1.2em 0 0.8em; font-size: 1.15em; font-weight: 600; line-height: 1.35; @@ -585,8 +585,11 @@ color: var(--muted-foreground); } -.paperclip-markdown :where(h1, h2, h3, h4) { - margin-top: 1.15rem; +.paperclip-markdown h1, +.paperclip-markdown h2, +.paperclip-markdown h3, +.paperclip-markdown h4 { + margin-top: 1.75rem; margin-bottom: 0.45rem; color: var(--foreground); font-weight: 600; From 2539950ad7c9fd674ff7b8eb2bdad429e08d70bb Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 16:50:18 -0500 Subject: [PATCH 12/19] fix: add two newlines after image drop/paste in markdown editor When dragging or pasting an image into a markdown editor field, the cursor would end up right next to the image making it hard to continue typing. Now inserts two newlines after the image so a new paragraph is ready. Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 372b8a4d..a2e49d0a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -251,6 +251,23 @@ export const MarkdownEditor = forwardRef try { const src = await handler(file); setUploadError(null); + // After MDXEditor inserts the image, ensure two newlines follow it + // so the cursor isn't stuck right next to the image. + setTimeout(() => { + const current = latestValueRef.current; + const updated = current.replace( + /!\[([^\]]*)\]\(([^)]+)\)(?!\n\n)/g, + "![$1]($2)\n\n", + ); + if (updated !== current) { + latestValueRef.current = updated; + ref.current?.setMarkdown(updated); + onChange(updated); + requestAnimationFrame(() => { + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); + }); + } + }, 100); return src; } catch (err) { const message = err instanceof Error ? err.message : "Image upload failed"; From fee3df2e6218c4b35d18ab8b07de2127501e587a Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 19:35:11 -0500 Subject: [PATCH 13/19] Make session compaction adapter-aware Co-Authored-By: Paperclip --- packages/adapter-utils/src/index.ts | 14 ++ .../adapter-utils/src/session-compaction.ts | 174 ++++++++++++++++++ packages/adapter-utils/src/types.ts | 1 + .../heartbeat-workspace-session.test.ts | 80 ++++++++ server/src/adapters/registry.ts | 7 + server/src/adapters/types.ts | 4 + server/src/services/heartbeat.ts | 41 +---- ui/src/components/AgentConfigForm.tsx | 105 +++++++++++ 8 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 packages/adapter-utils/src/session-compaction.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index cc3cd7e0..103cb68e 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -24,6 +24,20 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export type { + SessionCompactionPolicy, + NativeContextManagement, + AdapterSessionManagement, + ResolvedSessionCompactionPolicy, +} from "./session-compaction.js"; +export { + ADAPTER_SESSION_MANAGEMENT, + LEGACY_SESSIONED_ADAPTER_TYPES, + getAdapterSessionManagement, + readSessionCompactionOverride, + resolveSessionCompactionPolicy, + hasSessionCompactionThresholds, +} from "./session-compaction.js"; export { REDACTED_HOME_PATH_USER, redactHomePathUserSegments, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts new file mode 100644 index 00000000..0807c926 --- /dev/null +++ b/packages/adapter-utils/src/session-compaction.ts @@ -0,0 +1,174 @@ +export interface SessionCompactionPolicy { + enabled: boolean; + maxSessionRuns: number; + maxRawInputTokens: number; + maxSessionAgeHours: number; +} + +export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; + +export interface AdapterSessionManagement { + supportsSessionResume: boolean; + nativeContextManagement: NativeContextManagement; + defaultSessionCompaction: SessionCompactionPolicy; +} + +export interface ResolvedSessionCompactionPolicy { + policy: SessionCompactionPolicy; + adapterSessionManagement: AdapterSessionManagement | null; + explicitOverride: Partial; + source: "adapter_default" | "agent_override" | "legacy_fallback"; +} + +const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, +}; + +const DISABLED_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, +}; + +export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", +]); + +export const ADAPTER_SESSION_MANAGEMENT: Record = { + claude_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: DISABLED_SESSION_COMPACTION_POLICY, + }, + codex_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: DISABLED_SESSION_COMPACTION_POLICY, + }, + cursor: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + gemini_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + opencode_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + pi_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return undefined; + } + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + if (typeof value !== "string") return undefined; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; +} + +export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null { + if (!adapterType) return null; + return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null; +} + +export function readSessionCompactionOverride(runtimeConfig: unknown): Partial { + const runtime = isRecord(runtimeConfig) ? runtimeConfig : {}; + const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {}; + const compaction = isRecord( + heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction, + ) + ? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record + : {}; + + const explicit: Partial = {}; + const enabled = readBoolean(compaction.enabled); + const maxSessionRuns = readNumber(compaction.maxSessionRuns); + const maxRawInputTokens = readNumber(compaction.maxRawInputTokens); + const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours); + + if (enabled !== undefined) explicit.enabled = enabled; + if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns; + if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens; + if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours; + + return explicit; +} + +export function resolveSessionCompactionPolicy( + adapterType: string | null | undefined, + runtimeConfig: unknown, +): ResolvedSessionCompactionPolicy { + const adapterSessionManagement = getAdapterSessionManagement(adapterType); + const explicitOverride = readSessionCompactionOverride(runtimeConfig); + const hasExplicitOverride = Object.keys(explicitOverride).length > 0; + const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType)); + const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? { + ...DEFAULT_SESSION_COMPACTION_POLICY, + enabled: fallbackEnabled, + }; + + return { + policy: { + enabled: explicitOverride.enabled ?? basePolicy.enabled, + maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns, + maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens, + maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours, + }, + adapterSessionManagement, + explicitOverride, + source: hasExplicitOverride + ? "agent_override" + : adapterSessionManagement + ? "adapter_default" + : "legacy_fallback", + }; +} + +export function hasSessionCompactionThresholds(policy: Pick< + SessionCompactionPolicy, + "maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" +>) { + return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; +} + diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ade4648a..f907d4b4 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -216,6 +216,7 @@ export interface ServerAdapterModule { execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; sessionCodec?: AdapterSessionCodec; + sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index bca52142..a7189fca 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { agents } from "@paperclipai/db"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -20,6 +22,32 @@ function buildResolvedWorkspace(overrides: Partial = {} }; } +function buildAgent(adapterType: string, runtimeConfig: Record = {}) { + return { + id: "agent-1", + companyId: "company-1", + projectId: null, + goalId: null, + name: "Agent", + role: "engineer", + title: null, + icon: null, + status: "running", + reportsTo: null, + capabilities: null, + adapterType, + adapterConfig: {}, + runtimeConfig, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as typeof agents.$inferSelect; +} + describe("resolveRuntimeSessionParamsForWorkspace", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { const agentId = "agent-123"; @@ -151,3 +179,55 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(false); }); }); + +describe("parseSessionCompactionPolicy", () => { + it("disables Paperclip-managed rotation by default for codex and claude local", () => { + expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + }); + + it("keeps conservative defaults for adapters without confirmed native compaction", () => { + expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + }); + + it("lets explicit agent overrides win over adapter defaults", () => { + expect( + parseSessionCompactionPolicy( + buildAgent("codex_local", { + heartbeat: { + sessionCompaction: { + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + }, + }, + }), + ), + ).toEqual({ + enabled: true, + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + maxSessionAgeHours: 0, + }); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index e644900e..dcad7527 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { ServerAdapterModule } from "./types.js"; +import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, testEnvironment as claudeTestEnvironment, @@ -70,6 +71,7 @@ const claudeLocalAdapter: ServerAdapterModule = { execute: claudeExecute, testEnvironment: claudeTestEnvironment, sessionCodec: claudeSessionCodec, + sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, supportsLocalAgentJwt: true, agentConfigurationDoc: claudeAgentConfigurationDoc, @@ -81,6 +83,7 @@ const codexLocalAdapter: ServerAdapterModule = { execute: codexExecute, testEnvironment: codexTestEnvironment, sessionCodec: codexSessionCodec, + sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, @@ -93,6 +96,7 @@ const cursorLocalAdapter: ServerAdapterModule = { execute: cursorExecute, testEnvironment: cursorTestEnvironment, sessionCodec: cursorSessionCodec, + sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, @@ -104,6 +108,7 @@ const geminiLocalAdapter: ServerAdapterModule = { execute: geminiExecute, testEnvironment: geminiTestEnvironment, sessionCodec: geminiSessionCodec, + sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, agentConfigurationDoc: geminiAgentConfigurationDoc, @@ -123,6 +128,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { execute: openCodeExecute, testEnvironment: openCodeTestEnvironment, sessionCodec: openCodeSessionCodec, + sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, models: [], listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -134,6 +140,7 @@ const piLocalAdapter: ServerAdapterModule = { execute: piExecute, testEnvironment: piTestEnvironment, sessionCodec: piSessionCodec, + sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], listModels: listPiModels, supportsLocalAgentJwt: true, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index c5708d8a..88f27218 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -3,6 +3,7 @@ // imports (process/, http/, heartbeat.ts) don't need rewriting. export type { AdapterAgent, + AdapterSessionManagement, AdapterRuntime, UsageSummary, AdapterExecutionResult, @@ -15,5 +16,8 @@ export type { AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, + NativeContextManagement, + ResolvedSessionCompactionPolicy, + SessionCompactionPolicy, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1f67e5e6..fa3753d0 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -42,6 +42,11 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type SessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -49,14 +54,6 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const SESSIONED_LOCAL_ADAPTERS = new Set([ - "claude_local", - "codex_local", - "cursor", - "gemini_local", - "opencode_local", - "pi_local", -]); const heartbeatRunListColumns = { id: heartbeatRuns.id, @@ -133,13 +130,6 @@ type UsageTotals = { outputTokens: number; }; -type SessionCompactionPolicy = { - enabled: boolean; - maxSessionRuns: number; - maxRawInputTokens: number; - maxSessionAgeHours: number; -}; - type SessionCompactionDecision = { rotate: boolean; reason: string | null; @@ -296,23 +286,8 @@ function formatCount(value: number | null | undefined) { return value.toLocaleString("en-US"); } -function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { - const runtimeConfig = parseObject(agent.runtimeConfig); - const heartbeat = parseObject(runtimeConfig.heartbeat); - const compaction = parseObject( - heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction, - ); - const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType); - const enabled = compaction.enabled === undefined - ? supportsSessions - : asBoolean(compaction.enabled, supportsSessions); - - return { - enabled, - maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))), - maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))), - maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))), - }; +export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { + return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy; } export function resolveRuntimeSessionParamsForWorkspace(input: { @@ -743,7 +718,7 @@ export function heartbeatService(db: Db) { } const policy = parseSessionCompactionPolicy(agent); - if (!policy.enabled) { + if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { return { rotate: false, reason: null, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index abfc04fb..c6e48cd0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,11 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type ResolvedSessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; import type { Agent, AdapterEnvironmentTestResult, @@ -383,6 +388,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + const effectiveRuntimeConfig = useMemo(() => { + if (isCreate) { + return { + heartbeat: { + enabled: val!.heartbeatEnabled, + intervalSec: val!.intervalSec, + }, + }; + } + const mergedHeartbeat = { + ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" + ? runtimeConfig.heartbeat as Record + : {}), + ...overlay.heartbeat, + }; + return { + ...runtimeConfig, + heartbeat: mergedHeartbeat, + }; + }, [isCreate, overlay.heartbeat, runtimeConfig, val]); + const sessionCompaction = useMemo( + () => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig), + [adapterType, effectiveRuntimeConfig], + ); + const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement); return (
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> + {showSessionCompactionCard && ( + + )}
) : ( @@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> + {showSessionCompactionCard && ( + + )} +
+
Session compaction
+ + {sourceLabel} + +
+

+ {nativeSummary} +

+

+ {rotationDisabled + ? "No Paperclip-managed fresh-session thresholds are active for this adapter." + : "Paperclip will start a fresh session when one of these thresholds is reached."} +

+
+
+
Runs
+
{formatSessionThreshold(policy.maxSessionRuns, "runs")}
+
+
+
Raw input
+
{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}
+
+
+
Age
+
{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}
+
+
+

+ A large cumulative raw token total does not mean the full session is resent on every heartbeat. + {source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."} +

+ + ); +} + /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); From bba36ab4c05e2a6142a2054729ca29bc7da38a73 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 19:48:04 -0500 Subject: [PATCH 14/19] fix: convert archivedAt string to Date before Drizzle update The zod schema validates archivedAt as a datetime string, but Drizzle's timestamp column expects a Date object. The string was passed directly to db.update(), causing a 500 error. Now we convert the string to a Date in the route handler before calling the service. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- server/src/routes/projects.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index d9da3094..51555ff5 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -116,7 +116,11 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - const project = await svc.update(id, req.body); + const body = { ...req.body }; + if (typeof body.archivedAt === "string") { + body.archivedAt = new Date(body.archivedAt); + } + const project = await svc.update(id, body); if (!project) { res.status(404).json({ error: "Project not found" }); return; From cebd62cbb71f9288bcb277676a791050559b9a6b Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 07:54:36 -0500 Subject: [PATCH 15/19] Remove box border and add vertical margin to execution workspace toggle in new issue dialog Removes the rounded border around the execution workspace toggle section and increases top/bottom padding for better visual spacing. Co-Authored-By: Paperclip --- ui/src/components/NewIssueDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 5a9ce792..211af56a 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1008,8 +1008,8 @@ export function NewIssueDialog() { {currentProjectSupportsExecutionWorkspace && ( -
-
+
+
Use isolated issue checkout
From 5a9a4170e831ca6d130ddb86ac04ac5cb786558b Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 08:01:48 -0500 Subject: [PATCH 16/19] Remove box border from execution workspace toggle in issue properties panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same styling fix as NewIssueDialog — removes the rounded-md border from the workspace toggle in the issue detail view. Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 4781aea5..1c15f451 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -545,7 +545,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {currentProjectSupportsExecutionWorkspace && ( -
+
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"} From 4323d4bbda162ff0978a239b25d445891a0565df Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 09:10:40 -0500 Subject: [PATCH 17/19] feat: add agent skills tab and local dev helpers --- .gitignore | 1 + scripts/kill-dev.sh | 71 ++++++++++++++++++++++++++++ server/src/routes/access.ts | 89 ++++++++++++++++++++++++++++++++++++ ui/src/api/agents.ts | 8 ++++ ui/src/lib/queryKeys.ts | 3 ++ ui/src/pages/AgentDetail.tsx | 55 +++++++++++++++++++--- 6 files changed, 221 insertions(+), 6 deletions(-) create mode 100755 scripts/kill-dev.sh diff --git a/.gitignore b/.gitignore index f2c9b9a7..312c3969 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ tmp/ tests/e2e/test-results/ tests/e2e/playwright-report/ .superset/ +.claude/worktrees/ diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh new file mode 100755 index 00000000..2cb946e2 --- /dev/null +++ b/scripts/kill-dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Kill all local Paperclip dev server processes (across all worktrees). +# +# Usage: +# scripts/kill-dev.sh # kill all paperclip dev processes +# scripts/kill-dev.sh --dry # preview what would be killed +# + +set -euo pipefail + +DRY_RUN=false +if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN=true +fi + +# Collect PIDs of node processes running from any paperclip directory. +# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... +# Excludes postgres-related processes. +pids=() +lines=() + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + # skip postgres processes + [[ "$line" == *postgres* ]] && continue + pid=$(echo "$line" | awk '{print $2}') + pids+=("$pid") + lines+=("$line") +done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) + +if [[ ${#pids[@]} -eq 0 ]]; then + echo "No Paperclip dev processes found." + exit 0 +fi + +echo "Found ${#pids[@]} Paperclip dev process(es):" +echo "" + +for i in "${!pids[@]}"; do + line="${lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + # Shorten the command for readability + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" +done + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run — re-run without --dry to kill these processes." + exit 0 +fi + +echo "Sending SIGTERM..." +for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" +done + +# Give processes a moment to exit, then SIGKILL any stragglers +sleep 2 +for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " $pid still alive, sending SIGKILL..." + kill -9 "$pid" 2>/dev/null || true + fi +done + +echo "Done." diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index ee156091..a966d12b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null { if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" && + normalized !== "paperclip-create-plugin" && normalized !== "para-memory-files" ) return null; @@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null { return null; } +/** Resolve the Paperclip repo skills directory (built-in / managed skills). */ +function resolvePaperclipSkillsDir(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(moduleDir, "../../skills"), // published + path.resolve(process.cwd(), "skills"), // cwd (monorepo root) + path.resolve(moduleDir, "../../../skills"), // dev + ]; + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { /* skip */ } + } + return null; +} + +/** Parse YAML frontmatter from a SKILL.md file to extract the description. */ +function parseSkillFrontmatter(markdown: string): { description: string } { + const match = markdown.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { description: "" }; + const yaml = match[1]; + // Extract description — handles both single-line and multi-line YAML values + const descMatch = yaml.match( + /^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m + ); + if (!descMatch) return { description: "" }; + const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? ""; + return { + description: raw + .split("\n") + .map((l: string) => l.trim()) + .filter(Boolean) + .join(" ") + .trim(), + }; +} + +interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} + +/** Discover all available Claude Code skills from ~/.claude/skills/. */ +function listAvailableSkills(): AvailableSkill[] { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const claudeSkillsDir = path.join(homeDir, ".claude", "skills"); + const paperclipSkillsDir = resolvePaperclipSkillsDir(); + + // Build set of Paperclip-managed skill names + const paperclipSkillNames = new Set(); + if (paperclipSkillsDir) { + try { + for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory()) paperclipSkillNames.add(entry.name); + } + } catch { /* skip */ } + } + + const skills: AvailableSkill[] = []; + + try { + const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".")) continue; + const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md"); + let description = ""; + try { + const md = fs.readFileSync(skillMdPath, "utf8"); + description = parseSkillFrontmatter(md).description; + } catch { /* no SKILL.md or unreadable */ } + skills.push({ + name: entry.name, + description, + isPaperclipManaged: paperclipSkillNames.has(entry.name), + }); + } + } catch { /* ~/.claude/skills/ doesn't exist */ } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { const { claimSecretHash: _claimSecretHash, ...safe } = row; return safe; @@ -1610,6 +1695,10 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + router.get("/skills/available", (_req, res) => { + res.json({ skills: listAvailableSkills() }); + }); + router.get("/skills/index", (_req, res) => { res.json({ skills: [ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 85486af9..9008fbca 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -144,4 +144,12 @@ export const agentsApi = { ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + availableSkills: () => + api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; + +export interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 28d9b4e9..f0d96874 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -96,6 +96,9 @@ export const queryKeys = { liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, + skills: { + available: ["skills", "available"] as const, + }, plugins: { all: ["plugins"] as const, examples: ["plugins", "examples"] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 32c3b9d6..9faaf005 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -185,11 +185,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget"; +type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; - if (value === "budget") return "budget"; + if (value === "skills") return value; + if (value === "budget") return value; if (value === "runs") return value; return "dashboard"; } @@ -364,10 +365,12 @@ export function AgentDetail() { const canonicalTab = activeView === "configuration" ? "configuration" - : activeView === "runs" - ? "runs" - : activeView === "budget" - ? "budget" + : activeView === "skills" + ? "skills" + : activeView === "runs" + ? "runs" + : activeView === "budget" + ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); @@ -483,6 +486,8 @@ export function AgentDetail() { crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); + } else if (activeView === "skills") { + crumbs.push({ label: "Skills" }); } else if (activeView === "runs") { crumbs.push({ label: "Runs" }); } else if (activeView === "budget") { @@ -642,6 +647,7 @@ export function AgentDetail() { items={[ { value: "dashboard", label: "Dashboard" }, { value: "configuration", label: "Configuration" }, + { value: "skills", label: "Skills" }, { value: "runs", label: "Runs" }, { value: "budget", label: "Budget" }, ]} @@ -734,6 +740,13 @@ export function AgentDetail() { /> )} + {activeView === "skills" && ( + + )} + {activeView === "runs" && ( 0 + ? agent.adapterConfig.instructionsFilePath + : null; + + return ( +
+
+

Skills

+

+ Skills are reusable instruction bundles the agent can invoke from its local tool environment. + This view keeps the tab compile-safe and shows the current instructions file path while the broader skills listing work continues elsewhere in the tree. +

+

+ Agent: {agent.name} +

+
+
+ Instructions file +
+
+ {instructionsPath ?? "No instructions file configured for this agent."} +
+
+
+
+ ); +} + /* ---- Runs Tab ---- */ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) { From 4d9769c620d82779ffeb818af1de16b0ab9ca5e3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 09:21:44 -0500 Subject: [PATCH 18/19] fix: address review feedback on skills and session compaction --- .../adapter-utils/src/session-compaction.ts | 9 ++-- ui/src/components/MarkdownEditor.tsx | 9 +++- ui/src/pages/AgentDetail.tsx | 50 +++++++++++++++++-- ui/src/pages/ProjectDetail.tsx | 3 ++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 0807c926..308b54a3 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -27,7 +27,9 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { maxSessionAgeHours: 72, }; -const DISABLED_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { +// Adapters with native context management still participate in session resume, +// but Paperclip should not rotate them using threshold-based compaction. +const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { enabled: true, maxSessionRuns: 0, maxRawInputTokens: 0, @@ -47,12 +49,12 @@ export const ADAPTER_SESSION_MANAGEMENT: Record) { return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; } - diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index a2e49d0a..29a57a3a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -61,6 +61,10 @@ export interface MarkdownEditorRef { focus: () => void; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /* ---- Mention detection helpers ---- */ interface MentionState { @@ -255,9 +259,10 @@ export const MarkdownEditor = forwardRef // so the cursor isn't stuck right next to the image. setTimeout(() => { const current = latestValueRef.current; + const escapedSrc = escapeRegExp(src); const updated = current.replace( - /!\[([^\]]*)\]\(([^)]+)\)(?!\n\n)/g, - "![$1]($2)\n\n", + new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"), + "$1\n\n", ); if (updated !== current) { latestValueRef.current = updated; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 9faaf005..211f0e7f 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; +import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { ApiError } from "../api/client"; @@ -30,6 +30,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Tabs } from "@/components/ui/tabs"; import { Popover, @@ -743,7 +744,6 @@ export function AgentDetail() { {activeView === "skills" && ( )} @@ -1213,11 +1213,16 @@ function ConfigurationTab({ ); } -function SkillsTab({ agent }: { agent: Agent; companyId?: string }) { +function SkillsTab({ agent }: { agent: Agent }) { const instructionsPath = typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0 ? agent.adapterConfig.instructionsFilePath : null; + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.skills.available, + queryFn: () => agentsApi.availableSkills(), + }); + const skills = data?.skills ?? []; return (
@@ -1225,7 +1230,7 @@ function SkillsTab({ agent }: { agent: Agent; companyId?: string }) {

Skills

Skills are reusable instruction bundles the agent can invoke from its local tool environment. - This view keeps the tab compile-safe and shows the current instructions file path while the broader skills listing work continues elsewhere in the tree. + This view shows the current instructions file and the skills currently visible to the local agent runtime.

Agent: {agent.name} @@ -1238,11 +1243,48 @@ function SkillsTab({ agent }: { agent: Agent; companyId?: string }) { {instructionsPath ?? "No instructions file configured for this agent."}

+ +
+
+ Available skills +
+ {isLoading ? ( +

Loading available skills…

+ ) : error ? ( +

+ {error instanceof Error ? error.message : "Failed to load available skills."} +

+ ) : skills.length === 0 ? ( +

No local skills were found.

+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
); } +function SkillRow({ skill }: { skill: AvailableSkill }) { + return ( +
+
+ {skill.name} + + {skill.isPaperclipManaged ? "Paperclip" : "Local"} + +
+

+ {skill.description || "No description available."} +

+
+ ); +} + /* ---- Runs Tab ---- */ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) { diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index ebb7cec8..8727e80b 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -461,6 +461,9 @@ export function ProjectDetail() { if (cachedTab === "configuration") { return ; } + if (cachedTab === "budget") { + return ; + } if (isProjectPluginTab(cachedTab)) { return ; } From 2c747402a8da893ae7b23b663138d512c7f1a695 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 09:31:21 -0500 Subject: [PATCH 19/19] docs: add PR thinking path guidance to contributing --- CONTRIBUTING.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab420a24..1eba95e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ We really appreciate both small fixes and thoughtful larger changes. ## Two Paths to Get Your Pull Request Accepted ### Path 1: Small, Focused Changes (Fastest way to get merged) + - Pick **one** clear thing to fix/improve - Touch the **smallest possible number of files** - Make sure the change is very targeted and easy to review @@ -16,6 +17,7 @@ We really appreciate both small fixes and thoughtful larger changes. These almost always get merged quickly when they're clean. ### Path 2: Bigger or Impactful Changes + - **First** talk about it in Discord → #dev channel → Describe what you're trying to solve → Share rough ideas / approach @@ -30,12 +32,43 @@ These almost always get merged quickly when they're clean. PRs that follow this path are **much** more likely to be accepted, even when they're large. ## General Rules (both paths) + - Write clear commit messages - Keep PR title + description meaningful - One PR = one logical change (unless it's a small related group) - Run tests locally first - Be kind in discussions 😄 +## Writing a Good PR message + +Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.: + +### Thinking Path Example 1: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - There are many types of adapters for each LLM model provider +> - But LLM's have a context limit and not all agents can automatically compact their context +> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context +> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed +> - That way we can get optimal performance from any adapter/provider in Paperclip + +### Thinking Path Example 2: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - But humans want to watch the agents and oversee their work +> - Human users also operate in teams and so they need their own logins, profiles, views etc. +> - So we have a multi-user system for humans +> - But humans want to be able to update their own profile picture and avatar +> - But the avatar upload form wasn't saving the avatar to the file storage system +> - So this PR fixes the avatar upload form to use the file storage service +> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration + +Then have the rest of your normal PR message after the Thinking Path. + +This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks. + +Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots. + Questions? Just ask in #dev — we're happy to help. Happy hacking!