From 5c7d2116e96d06c9d0cbd19fce620cb3bc44e1e7 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Thu, 12 Mar 2026 14:33:11 -0500 Subject: [PATCH 01/28] Fix local-cli skill install for moved .agents skills Co-Authored-By: Paperclip --- cli/src/commands/client/agent.ts | 49 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 36eb04e6..a6e86277 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -40,7 +40,9 @@ interface SkillsInstallSummary { const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../../../../.agents/skills"), // dev: cli/src/commands/client -> repo root/.agents/skills path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills + path.resolve(process.cwd(), ".agents/skills"), path.resolve(process.cwd(), "skills"), ]; @@ -85,8 +87,48 @@ async function installSkillsForTarget( const target = path.join(targetSkillsDir, entry.name); const existing = await fs.lstat(target).catch(() => null); if (existing) { - summary.skipped.push(entry.name); - continue; + if (existing.isSymbolicLink()) { + let linkedPath: string | null = null; + try { + linkedPath = await fs.readlink(target); + } catch (err) { + await fs.unlink(target); + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + continue; + } catch (linkErr) { + summary.failed.push({ + name: entry.name, + error: + err instanceof Error && linkErr instanceof Error + ? `${err.message}; then ${linkErr.message}` + : err instanceof Error + ? err.message + : `Failed to recover broken symlink: ${String(err)}`, + }); + continue; + } + } + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + const linkedTargetExists = await fs + .stat(resolvedLinkedPath) + .then(() => true) + .catch(() => false); + + if (!linkedTargetExists) { + await fs.unlink(target); + } else { + summary.skipped.push(entry.name); + continue; + } + } else { + summary.skipped.push(entry.name); + continue; + } } try { @@ -98,6 +140,7 @@ async function installSkillsForTarget( error: err instanceof Error ? err.message : String(err), }); } + } } return summary; @@ -213,7 +256,7 @@ export function registerAgentCommands(program: Command): void { const skillsDir = await resolvePaperclipSkillsDir(); if (!skillsDir) { throw new Error( - "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + "Could not locate local Paperclip skills directory. Expected ./skills or ./.agents/skills in the repo checkout.", ); } From 69b9e45eaf923df4c8e8694e6c30387e4094d11c Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:39:50 -0500 Subject: [PATCH 02/28] 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 Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 12cc6f88..a90efa9a 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -5,7 +5,6 @@ import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router" import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; -import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; @@ -248,12 +247,15 @@ export function Layout() {
- + + + Documentation +
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() { ) : ( )} - {loading ? "Opening..." : "Open Issue"} + {loading ? "Creating..." : "Create & Open Issue"} )}
From c9259bbec0d201eb864a7058c7d1fef0114c28c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:04:28 -0500 Subject: [PATCH 12/28] Fix manual company switch route sync Co-Authored-By: Paperclip --- ui/src/components/Layout.tsx | 11 ++++++++- ui/src/context/CompanyContext.tsx | 3 +-- ui/src/lib/company-selection.test.ts | 34 ++++++++++++++++++++++++++++ ui/src/lib/company-selection.ts | 18 +++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/company-selection.test.ts create mode 100644 ui/src/lib/company-selection.ts diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a90efa9a..e484b265 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -22,6 +22,7 @@ import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; +import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; @@ -36,6 +37,7 @@ export function Layout() { loading: companiesLoading, selectedCompany, selectedCompanyId, + selectionSource, setSelectedCompanyId, } = useCompany(); const { theme, toggleTheme } = useTheme(); @@ -88,7 +90,13 @@ export function Layout() { return; } - if (selectedCompanyId !== matchedCompany.id) { + if ( + shouldSyncCompanySelectionFromRoute({ + selectionSource, + selectedCompanyId, + routeCompanyId: matchedCompany.id, + }) + ) { setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ @@ -99,6 +107,7 @@ export function Layout() { location.pathname, location.search, navigate, + selectionSource, selectedCompanyId, setSelectedCompanyId, ]); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index eafc7f55..fb074f33 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared"; import { companiesApi } from "../api/companies"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; - -type CompanySelectionSource = "manual" | "route_sync" | "bootstrap"; +import type { CompanySelectionSource } from "../lib/company-selection"; type CompanySelectionOptions = { source?: CompanySelectionSource }; interface CompanyContextValue { diff --git a/ui/src/lib/company-selection.test.ts b/ui/src/lib/company-selection.test.ts new file mode 100644 index 00000000..a8533a4b --- /dev/null +++ b/ui/src/lib/company-selection.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldSyncCompanySelectionFromRoute } from "./company-selection"; + +describe("shouldSyncCompanySelectionFromRoute", () => { + it("does not resync when selection already matches the route", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "route_sync", + selectedCompanyId: "pap", + routeCompanyId: "pap", + }), + ).toBe(false); + }); + + it("defers route sync while a manual company switch is in flight", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "manual", + selectedCompanyId: "pap", + routeCompanyId: "ret", + }), + ).toBe(false); + }); + + it("syncs back to the route company for non-manual mismatches", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "route_sync", + selectedCompanyId: "pap", + routeCompanyId: "ret", + }), + ).toBe(true); + }); +}); diff --git a/ui/src/lib/company-selection.ts b/ui/src/lib/company-selection.ts new file mode 100644 index 00000000..ce02cb4d --- /dev/null +++ b/ui/src/lib/company-selection.ts @@ -0,0 +1,18 @@ +export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap"; + +export function shouldSyncCompanySelectionFromRoute(params: { + selectionSource: CompanySelectionSource; + selectedCompanyId: string | null; + routeCompanyId: string; +}): boolean { + const { selectionSource, selectedCompanyId, routeCompanyId } = params; + + if (selectedCompanyId === routeCompanyId) return false; + + // Let manual company switches finish their remembered-path navigation first. + if (selectionSource === "manual" && selectedCompanyId) { + return false; + } + + return true; +} From 575a2fd83f83dfb5cfa2f7a89e9f02b2269fce88 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:11:37 -0500 Subject: [PATCH 13/28] 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 --- ui/src/components/NewIssueDialog.tsx | 96 ++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 01f210ed..c017306c 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + parseAssigneeValue, +} from "../lib/assignees"; import { Dialog, DialogContent, @@ -63,7 +68,8 @@ interface IssueDraft { description: string; status: string; priority: string; - assigneeId: string; + assigneeValue: string; + assigneeId?: string; projectId: string; assigneeModelOverride: string; assigneeThinkingEffort: string; @@ -173,7 +179,7 @@ export function NewIssueDialog() { const [description, setDescription] = useState(""); const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState(""); - const [assigneeId, setAssigneeId] = useState(""); + const [assigneeValue, setAssigneeValue] = useState(""); const [projectId, setProjectId] = useState(""); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); @@ -220,7 +226,11 @@ export function NewIssueDialog() { userId: currentUserId, }); - const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null; + const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]); + const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; + const selectedAssigneeUserId = selectedAssignee.assigneeUserId; + + const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); @@ -295,7 +305,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -307,7 +317,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -330,7 +340,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -340,7 +350,11 @@ export function NewIssueDialog() { setDescription(draft.description); setStatus(draft.status || "todo"); setPriority(draft.priority); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId); + setAssigneeValue( + newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId + ? assigneeValueFromSelection(newIssueDefaults) + : (draft.assigneeValue ?? draft.assigneeId ?? ""), + ); setProjectId(newIssueDefaults.projectId ?? draft.projectId); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); @@ -350,7 +364,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -390,7 +404,7 @@ export function NewIssueDialog() { setDescription(""); setStatus("todo"); setPriority(""); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeOptionsOpen(false); setAssigneeModelOverride(""); @@ -406,7 +420,7 @@ export function NewIssueDialog() { function handleCompanyChange(companyId: string) { if (companyId === effectiveCompanyId) return; setDialogCompanyId(companyId); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); @@ -443,7 +457,8 @@ export function NewIssueDialog() { description: description.trim() || undefined, status, priority: priority || "medium", - ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), + ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), + ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(projectId ? { projectId } : {}), ...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), @@ -475,7 +490,9 @@ export function NewIssueDialog() { const hasDraft = title.trim().length > 0 || description.trim().length > 0; const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentPriority = priorities.find((p) => p.value === priority); - const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); + const currentAssignee = selectedAssigneeAgentId + ? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId) + : null; const currentProject = orderedProjects.find((project) => project.id === projectId); const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? currentProject?.executionWorkspacePolicy ?? null @@ -497,16 +514,18 @@ export function NewIssueDialog() { : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( - () => - sortAgentsByRecency( + () => [ + ...currentUserAssigneeOption(currentUserId), + ...sortAgentsByRecency( (agents ?? []).filter((agent) => agent.status !== "terminated"), recentAssigneeIds, ).map((agent) => ({ - id: agent.id, + id: assigneeValueFromSelection({ assigneeAgentId: agent.id }), label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), - [agents, recentAssigneeIds], + ], + [agents, currentUserId, recentAssigneeIds], ); const projectOptions = useMemo( () => @@ -710,7 +729,16 @@ export function NewIssueDialog() { } if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); - assigneeSelectorRef.current?.focus(); + if (assigneeValue) { + // Assignee already set — skip to project or description + if (projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } + } else { + assigneeSelectorRef.current?.focus(); + } } }} autoFocus @@ -723,33 +751,49 @@ export function NewIssueDialog() { For { if (id) trackRecentAssignee(id); setAssigneeId(id); }} + onChange={(value) => { + const nextAssignee = parseAssigneeValue(value); + if (nextAssignee.assigneeAgentId) { + trackRecentAssignee(nextAssignee.assigneeAgentId); + } + setAssigneeValue(value); + }} onConfirm={() => { - projectSelectorRef.current?.focus(); + if (projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } }} renderTriggerValue={(option) => - option && currentAssignee ? ( - <> - + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( {option.label} - + ) ) : ( Assignee ) } renderOption={(option) => { if (!option.id) return {option.label}; - const assignee = (agents ?? []).find((agent) => agent.id === option.id); + const assignee = parseAssigneeValue(option.id).assigneeAgentId + ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId) + : null; return ( <> - + {assignee ? : null} {option.label} ); From 2246d5f1eb59e4a0883fc3101d79994ed3135268 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:12:38 -0500 Subject: [PATCH 14/28] Add me and unassigned assignee options Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 29 +++--- ui/src/components/IssuesList.tsx | 123 ++++++++++++++++++++------ ui/src/context/DialogContext.tsx | 1 + ui/src/lib/assignees.test.ts | 53 +++++++++++ ui/src/lib/assignees.ts | 51 +++++++++++ ui/src/pages/IssueDetail.tsx | 3 +- 6 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 ui/src/lib/assignees.test.ts create mode 100644 ui/src/lib/assignees.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ca8e1bd4..cf4b6a43 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; - const userLabel = (userId: string | null | undefined) => - userId - ? userId === "local-board" - ? "Board" - : currentUserId && userId === currentUserId - ? "Me" - : userId.slice(0, 5) - : null; + const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); @@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > No assignee - {issue.createdByUserId && ( + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6899bd5c..442f8ae4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; +import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; @@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } -function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { +function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { let result = issues; if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId)); + if (state.assignees.length > 0) { + result = result.filter((issue) => { + for (const assignee of state.assignees) { + if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; + if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; + if (issue.assigneeAgentId === assignee) return true; + } + return false; + }); + } if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); return result; } @@ -165,6 +176,11 @@ export function IssuesList({ }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -224,9 +240,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState); + const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -253,13 +269,21 @@ export function IssuesList({ .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } // assignee - const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned"); + const groups = groupBy( + filtered, + (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), + ); return Object.keys(groups).map((key) => ({ key, - label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)), + label: + key === "__unassigned" + ? "Unassigned" + : key.startsWith("__user:") + ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") + : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); const newIssueDefaults = (groupKey?: string) => { const defaults: Record = {}; @@ -267,13 +291,16 @@ export function IssuesList({ if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; - else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey; + else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") { + if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); + else defaults.assigneeAgentId = groupKey; + } } return defaults; }; - const assignIssue = (issueId: string, assigneeAgentId: string | null) => { - onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); + const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }; @@ -419,22 +446,37 @@ export function IssuesList({ {/* Assignee */} - {agents && agents.length > 0 && ( -
- Assignee -
- {agents.map((agent) => ( - - ))} -
+
+ Assignee +
+ + {currentUserId && ( + + )} + {(agents ?? []).map((agent) => ( + + ))}
- )} +
{labels && labels.length > 0 && (
@@ -683,6 +725,13 @@ export function IssuesList({ > {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( + ) : issue.assigneeUserId ? ( + + + + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} + ) : ( @@ -701,7 +750,7 @@ export function IssuesList({ > setAssigneeSearch(e.target.value)} autoFocus @@ -710,16 +759,32 @@ export function IssuesList({ + {currentUserId && ( + + )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; @@ -737,7 +802,7 @@ export function IssuesList({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - assignIssue(issue.id, agent.id); + assignIssue(issue.id, agent.id, null); }} > diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index ef7b12b8..904ceb88 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,7 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + assigneeUserId?: string; title?: string; description?: string; } diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts new file mode 100644 index 00000000..1ce22ef7 --- /dev/null +++ b/ui/src/lib/assignees.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + formatAssigneeUserLabel, + parseAssigneeValue, +} from "./assignees"; + +describe("assignee selection helpers", () => { + it("encodes and parses agent assignees", () => { + const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" }); + + expect(value).toBe("agent:agent-123"); + expect(parseAssigneeValue(value)).toEqual({ + assigneeAgentId: "agent-123", + assigneeUserId: null, + }); + }); + + it("encodes and parses current-user assignees", () => { + const [option] = currentUserAssigneeOption("local-board"); + + expect(option).toEqual({ + id: "user:local-board", + label: "Me", + searchText: "me board human local-board", + }); + expect(parseAssigneeValue(option.id)).toEqual({ + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + }); + + it("treats an empty selection as no assignee", () => { + expect(parseAssigneeValue("")).toEqual({ + assigneeAgentId: null, + assigneeUserId: null, + }); + }); + + it("keeps backward compatibility for raw agent ids in saved drafts", () => { + expect(parseAssigneeValue("legacy-agent-id")).toEqual({ + assigneeAgentId: "legacy-agent-id", + assigneeUserId: null, + }); + }); + + it("formats current and board user labels consistently", () => { + expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me"); + expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); + expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts new file mode 100644 index 00000000..274bcd40 --- /dev/null +++ b/ui/src/lib/assignees.ts @@ -0,0 +1,51 @@ +export interface AssigneeSelection { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AssigneeOption { + id: string; + label: string; + searchText?: string; +} + +export function assigneeValueFromSelection(selection: Partial): string { + if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; + if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; + return ""; +} + +export function parseAssigneeValue(value: string): AssigneeSelection { + if (!value) { + return { assigneeAgentId: null, assigneeUserId: null }; + } + if (value.startsWith("agent:")) { + const assigneeAgentId = value.slice("agent:".length); + return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null }; + } + if (value.startsWith("user:")) { + const assigneeUserId = value.slice("user:".length); + return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null }; + } + // Backward compatibility for older drafts/defaults that stored a raw agent id. + return { assigneeAgentId: value, assigneeUserId: null }; +} + +export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] { + if (!currentUserId) return []; + return [{ + id: assigneeValueFromSelection({ assigneeUserId: currentUserId }), + label: "Me", + searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`, + }]; +} + +export function formatAssigneeUserLabel( + userId: string | null | undefined, + currentUserId: string | null | undefined, +): string | null { + if (!userId) return null; + if (currentUserId && userId === currentUserId) return "Me"; + if (userId === "local-board") return "Board"; + return userId.slice(0, 5); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index bb152e17..9a43f26a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -304,8 +304,7 @@ export function IssueDetail() { options.push({ id: `agent:${agent.id}`, label: agent.name }); } if (currentUserId) { - const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; - options.push({ id: `user:${currentUserId}`, label }); + options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; }, [agents, currentUserId]); From 7d1748b3a7bd6bc354a20c6f8f66b7bbafd4819f Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 08:49:11 -0500 Subject: [PATCH 15/28] feat: optimize heartbeat token usage Co-Authored-By: Paperclip --- .../skills}/create-agent-adapter/SKILL.md | 0 .../2026-03-13-TOKEN-OPTIMIZATION-PLAN.md | 2 +- docs/adapters/creating-an-adapter.md | 2 +- packages/adapter-utils/src/server-utils.ts | 10 + packages/adapter-utils/src/types.ts | 1 + .../claude-local/src/server/execute.ts | 24 +- .../claude-local/src/ui/build-config.ts | 1 + .../codex-local/src/server/execute.ts | 29 +- .../codex-local/src/ui/build-config.ts | 1 + .../cursor-local/src/server/execute.ts | 31 +- .../cursor-local/src/ui/build-config.ts | 1 + .../gemini-local/src/server/execute.ts | 30 +- .../gemini-local/src/ui/build-config.ts | 1 + .../opencode-local/src/server/execute.ts | 27 +- .../opencode-local/src/ui/build-config.ts | 1 + .../adapters/pi-local/src/server/execute.ts | 37 +- .../adapters/pi-local/src/ui/build-config.ts | 1 + packages/shared/src/validators/agent.ts | 1 + ...08-46-token-optimization-implementation.md | 40 ++ .../heartbeat-workspace-session.test.ts | 16 +- server/src/routes/access.ts | 10 +- server/src/routes/agents.ts | 29 ++ server/src/routes/issues.ts | 94 ++++- server/src/services/heartbeat.ts | 348 ++++++++++++++++-- server/src/services/issues.ts | 65 +++- skills/paperclip/SKILL.md | 16 +- ui/src/components/AgentConfigForm.tsx | 73 ++-- ui/src/components/agent-config-primitives.tsx | 4 +- 28 files changed, 800 insertions(+), 95 deletions(-) rename {skills => .agents/skills}/create-agent-adapter/SKILL.md (100%) create mode 100644 report/2026-03-13-08-46-token-optimization-implementation.md diff --git a/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md similarity index 100% rename from skills/create-agent-adapter/SKILL.md rename to .agents/skills/create-agent-adapter/SKILL.md diff --git a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md index 678444ac..e85cfdfc 100644 --- a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md +++ b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md @@ -121,7 +121,7 @@ Local adapters inject repo skills into runtime skill directories. 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 diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index e33b5411..fae0e4b3 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -6,7 +6,7 @@ summary: Guide to building a custom adapter Build a custom adapter to connect Paperclip to any agent runtime. -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. ## Package Structure diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 30f0c9bd..52e52b4c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -112,6 +112,16 @@ export function renderTemplate(template: string, data: Record) return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); } +export function joinPromptSections( + sections: Array, + separator = "\n\n", +) { + return sections + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + .join(separator); +} + export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 6503e5a1..df0d075a 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -99,6 +99,7 @@ export interface AdapterInvocationMeta { commandNotes?: string[]; env?: Record; prompt?: string; + promptMetrics?: Record; context?: Record; } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index be85439d..13d92df8 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -363,7 +364,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 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 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 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 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`], env: redactEnvForLogs(env), prompt, + promptMetrics, context, }); } diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 3abfd6cd..0d425cf1 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record 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; diff --git a/report/2026-03-13-08-46-token-optimization-implementation.md b/report/2026-03-13-08-46-token-optimization-implementation.md new file mode 100644 index 00000000..9bee83ca --- /dev/null +++ b/report/2026-03-13-08-46-token-optimization-implementation.md @@ -0,0 +1,40 @@ +# Token Optimization Implementation Report + +Implemented the token-optimization plan across heartbeat orchestration, issue context APIs, adapter prompt construction, skill exposure, and agent configuration UX. + +The main behavior changes are: + +- Heartbeat telemetry now normalizes sessioned local adapter usage as per-run deltas instead of blindly trusting cumulative session totals. +- Timer and manual wakes now preserve task sessions by default; fresh sessions are forced only for explicit `forceFreshSession` wakes or new issue assignment wakes. +- Heartbeat session rotation is now policy-driven in the control plane, with a handoff note injected when a session is compacted and restarted. +- Paperclip issue context now has incremental APIs: `GET /api/agents/me/inbox-lite`, `GET /api/issues/:id/heartbeat-context`, and comment delta queries via `GET /api/issues/:id/comments?after=...&order=asc`. +- The `paperclip` skill now teaches agents to use those compact/incremental APIs first, while keeping full-thread fetches as a cold-start fallback. +- All local adapters now separate first-session bootstrap prompts from per-heartbeat prompt templates, and emit prompt size metrics in invocation metadata. +- Adapter create flows now persist `bootstrapPromptTemplate` correctly. +- The agent config UI now explains the difference between bootstrap prompts and heartbeat prompts and warns about prompt churn. +- Runtime skill defaults now include `paperclip`, `para-memory-files`, and `paperclip-create-agent`. `create-agent-adapter` was moved to `.agents/skills/create-agent-adapter`. + +Files with the most important implementation work: + +- `server/src/services/heartbeat.ts` +- `server/src/services/issues.ts` +- `server/src/routes/issues.ts` +- `server/src/routes/agents.ts` +- `server/src/routes/access.ts` +- `skills/paperclip/SKILL.md` +- `packages/adapters/*/src/server/execute.ts` +- `packages/adapters/*/src/ui/build-config.ts` +- `ui/src/components/AgentConfigForm.tsx` + +Verification completed successfully: + +- `pnpm -r typecheck` +- `pnpm test:run` +- `pnpm build` + +While verifying, I also fixed two existing embedded-postgres typing mismatches so repo-wide `typecheck` and `build` pass again: + +- `packages/db/src/migration-runtime.ts` +- `cli/src/commands/worktree.ts` + +Next useful follow-up is measuring the before/after effect in real runs now that telemetry is less misleading and prompt/session reuse behavior is consistent across adapters. diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 650556d1..bca52142 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -93,16 +93,26 @@ describe("shouldResetTaskSessionForWake", () => { expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true); }); - it("resets session context on timer heartbeats", () => { - expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(true); + it("preserves session context on timer heartbeats", () => { + expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false); }); - it("resets session context on manual on-demand invokes", () => { + it("preserves session context on manual on-demand invokes by default", () => { expect( shouldResetTaskSessionForWake({ wakeSource: "on_demand", wakeTriggerDetail: "manual", }), + ).toBe(false); + }); + + it("resets session context when a fresh session is explicitly requested", () => { + expect( + shouldResetTaskSessionForWake({ + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + forceFreshSession: true, + }), ).toBe(true); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index c13366ff..ee156091 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -97,7 +97,11 @@ function requestBaseUrl(req: Request) { function readSkillMarkdown(skillName: string): string | null { const normalized = skillName.trim().toLowerCase(); - if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") + if ( + normalized !== "paperclip" && + normalized !== "paperclip-create-agent" && + normalized !== "para-memory-files" + ) return null; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ @@ -1610,6 +1614,10 @@ export function accessRoutes( res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, + { + name: "para-memory-files", + path: "/api/skills/para-memory-files" + }, { name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b1b53759..6c60b644 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -575,6 +575,34 @@ export function agentRoutes(db: Db) { res.json({ ...agent, chainOfCommand }); }); + router.get("/agents/me/inbox-lite", async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { + res.status(401).json({ error: "Agent authentication required" }); + return; + } + + const issuesSvc = issueService(db); + const rows = await issuesSvc.list(req.actor.companyId, { + assigneeAgentId: req.actor.agentId, + status: "todo,in_progress,blocked", + }); + + res.json( + rows.map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + status: issue.status, + priority: issue.priority, + projectId: issue.projectId, + goalId: issue.goalId, + parentId: issue.parentId, + updatedAt: issue.updatedAt, + activeRun: issue.activeRun, + })), + ); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); @@ -1275,6 +1303,7 @@ export function agentRoutes(db: Db) { contextSnapshot: { triggeredBy: req.actor.type, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, + forceFreshSession: req.body.forceFreshSession === true, }, }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f02067a6..bc938910 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -314,6 +314,79 @@ export function issueRoutes(db: Db, storage: StorageService) { }); }); + router.get("/issues/:id/heartbeat-context", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + + const wakeCommentId = + typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0 + ? req.query.wakeCommentId.trim() + : null; + + const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([ + svc.getAncestors(issue.id), + issue.projectId ? projectsSvc.getById(issue.projectId) : null, + issue.goalId + ? goalsSvc.getById(issue.goalId) + : !issue.projectId + ? goalsSvc.getDefaultCompanyGoal(issue.companyId) + : null, + svc.getCommentCursor(issue.id), + wakeCommentId ? svc.getComment(wakeCommentId) : null, + ]); + + res.json({ + issue: { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + status: issue.status, + priority: issue.priority, + projectId: issue.projectId, + goalId: goal?.id ?? issue.goalId, + parentId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + updatedAt: issue.updatedAt, + }, + ancestors: ancestors.map((ancestor) => ({ + id: ancestor.id, + identifier: ancestor.identifier, + title: ancestor.title, + status: ancestor.status, + priority: ancestor.priority, + })), + project: project + ? { + id: project.id, + name: project.name, + status: project.status, + targetDate: project.targetDate, + } + : null, + goal: goal + ? { + id: goal.id, + title: goal.title, + status: goal.status, + level: goal.level, + parentId: goal.parentId, + } + : null, + commentCursor, + wakeComment: + wakeComment && wakeComment.issueId === issue.id + ? wakeComment + : null, + }); + }); + router.post("/issues/:id/read", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -791,7 +864,26 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } assertCompanyAccess(req, issue.companyId); - const comments = await svc.listComments(id); + const afterCommentId = + typeof req.query.after === "string" && req.query.after.trim().length > 0 + ? req.query.after.trim() + : typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0 + ? req.query.afterCommentId.trim() + : null; + const order = + typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc" + ? "asc" + : "desc"; + const limitRaw = + typeof req.query.limit === "string" && req.query.limit.trim().length > 0 + ? Number(req.query.limit) + : null; + const limit = limitRaw && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : null; + const comments = await svc.listComments(id, { + afterCommentId, + order, + limit, + }); res.json(comments); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f0665c9a..1a9dba74 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,7 +18,7 @@ import { logger } from "../middleware/logger.js"; import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js"; -import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js"; +import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; @@ -47,6 +47,14 @@ 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, @@ -117,6 +125,26 @@ interface WakeupOptions { contextSnapshot?: Record; } +type UsageTotals = { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}; + +type SessionCompactionPolicy = { + enabled: boolean; + maxSessionRuns: number; + maxRawInputTokens: number; + maxSessionAgeHours: number; +}; + +type SessionCompactionDecision = { + rotate: boolean; + reason: string | null; + handoffMarkdown: string | null; + previousRunId: string | null; +}; + interface ParsedIssueAssigneeAdapterOverrides { adapterConfig: Record | null; useProjectWorkspace: boolean | null; @@ -142,6 +170,88 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null { + if (!usage) return null; + return { + inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))), + cachedInputTokens: Math.max(0, Math.floor(asNumber(usage.cachedInputTokens, 0))), + outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))), + }; +} + +function readRawUsageTotals(usageJson: unknown): UsageTotals | null { + const parsed = parseObject(usageJson); + if (Object.keys(parsed).length === 0) return null; + + const inputTokens = Math.max( + 0, + Math.floor(asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0))), + ); + const cachedInputTokens = Math.max( + 0, + Math.floor(asNumber(parsed.rawCachedInputTokens, asNumber(parsed.cachedInputTokens, 0))), + ); + const outputTokens = Math.max( + 0, + Math.floor(asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0))), + ); + + if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) { + return null; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + }; +} + +function deriveNormalizedUsageDelta(current: UsageTotals | null, previous: UsageTotals | null): UsageTotals | null { + if (!current) return null; + if (!previous) return { ...current }; + + const inputTokens = current.inputTokens >= previous.inputTokens + ? current.inputTokens - previous.inputTokens + : current.inputTokens; + const cachedInputTokens = current.cachedInputTokens >= previous.cachedInputTokens + ? current.cachedInputTokens - previous.cachedInputTokens + : current.cachedInputTokens; + const outputTokens = current.outputTokens >= previous.outputTokens + ? current.outputTokens - previous.outputTokens + : current.outputTokens; + + return { + inputTokens: Math.max(0, inputTokens), + cachedInputTokens: Math.max(0, cachedInputTokens), + outputTokens: Math.max(0, outputTokens), + }; +} + +function formatCount(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) return "0"; + 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 resolveRuntimeSessionParamsForWorkspace(input: { agentId: string; previousSessionParams: Record | null; @@ -246,29 +356,20 @@ function deriveTaskKey( export function shouldResetTaskSessionForWake( contextSnapshot: Record | null | undefined, ) { + if (contextSnapshot?.forceFreshSession === true) return true; + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); if (wakeReason === "issue_assigned") return true; - - const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource); - if (wakeSource === "timer") return true; - - const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail); - return wakeSource === "on_demand" && wakeTriggerDetail === "manual"; + return false; } function describeSessionResetReason( contextSnapshot: Record | null | undefined, ) { + if (contextSnapshot?.forceFreshSession === true) return "forceFreshSession was requested"; + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); if (wakeReason === "issue_assigned") return "wake reason is issue_assigned"; - - const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource); - if (wakeSource === "timer") return "wake source is timer"; - - const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail); - if (wakeSource === "on_demand" && wakeTriggerDetail === "manual") { - return "this is a manual invoke"; - } return null; } @@ -501,6 +602,159 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getLatestRunForSession( + agentId: string, + sessionId: string, + opts?: { excludeRunId?: string | null }, + ) { + const conditions = [ + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.sessionIdAfter, sessionId), + ]; + if (opts?.excludeRunId) { + conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); + } + return db + .select() + .from(heartbeatRuns) + .where(and(...conditions)) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function resolveNormalizedUsageForSession(input: { + agentId: string; + runId: string; + sessionId: string | null; + rawUsage: UsageTotals | null; + }) { + const { agentId, runId, sessionId, rawUsage } = input; + if (!sessionId || !rawUsage) { + return { + normalizedUsage: rawUsage, + previousRawUsage: null as UsageTotals | null, + derivedFromSessionTotals: false, + }; + } + + const previousRun = await getLatestRunForSession(agentId, sessionId, { excludeRunId: runId }); + const previousRawUsage = readRawUsageTotals(previousRun?.usageJson); + return { + normalizedUsage: deriveNormalizedUsageDelta(rawUsage, previousRawUsage), + previousRawUsage, + derivedFromSessionTotals: previousRawUsage !== null, + }; + } + + async function evaluateSessionCompaction(input: { + agent: typeof agents.$inferSelect; + sessionId: string | null; + issueId: string | null; + }): Promise { + const { agent, sessionId, issueId } = input; + if (!sessionId) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const policy = parseSessionCompactionPolicy(agent); + if (!policy.enabled) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const runs = await db + .select({ + id: heartbeatRuns.id, + createdAt: heartbeatRuns.createdAt, + usageJson: heartbeatRuns.usageJson, + resultJson: heartbeatRuns.resultJson, + error: heartbeatRuns.error, + }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId))) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(Math.max(policy.maxSessionRuns + 1, 4)); + + if (runs.length === 0) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const latestRun = runs[0] ?? null; + const oldestRun = runs[runs.length - 1] ?? latestRun; + const latestRawUsage = readRawUsageTotals(latestRun?.usageJson); + const sessionAgeHours = + latestRun && oldestRun + ? Math.max( + 0, + (new Date(latestRun.createdAt).getTime() - new Date(oldestRun.createdAt).getTime()) / (1000 * 60 * 60), + ) + : 0; + + let reason: string | null = null; + if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) { + reason = `session exceeded ${policy.maxSessionRuns} runs`; + } else if ( + policy.maxRawInputTokens > 0 && + latestRawUsage && + latestRawUsage.inputTokens >= policy.maxRawInputTokens + ) { + reason = + `session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` + + `(threshold ${formatCount(policy.maxRawInputTokens)})`; + } else if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) { + reason = `session age reached ${Math.floor(sessionAgeHours)} hours`; + } + + if (!reason || !latestRun) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: latestRun?.id ?? null, + }; + } + + const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson); + const latestTextSummary = + readNonEmptyString(latestSummary?.summary) ?? + readNonEmptyString(latestSummary?.result) ?? + readNonEmptyString(latestSummary?.message) ?? + readNonEmptyString(latestRun.error); + + const handoffMarkdown = [ + "Paperclip session handoff:", + `- Previous session: ${sessionId}`, + issueId ? `- Issue: ${issueId}` : "", + `- Rotation reason: ${reason}`, + latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "", + "Continue from the current task state. Rebuild only the minimum context you need.", + ] + .filter(Boolean) + .join("\n"); + + return { + rotate: true, + reason, + handoffMarkdown, + previousRunId: latestRun.id, + }; + } + async function resolveSessionBeforeForWakeup( agent: typeof agents.$inferSelect, taskKey: string | null, @@ -1016,9 +1270,10 @@ export function heartbeatService(db: Db) { run: typeof heartbeatRuns.$inferSelect, result: AdapterExecutionResult, session: { legacySessionId: string | null }, + normalizedUsage?: UsageTotals | null, ) { await ensureRuntimeState(agent); - const usage = result.usage; + const usage = normalizedUsage ?? normalizeUsageTotals(result.usage); const inputTokens = usage?.inputTokens ?? 0; const outputTokens = usage?.outputTokens ?? 0; const cachedInputTokens = usage?.cachedInputTokens ?? 0; @@ -1270,15 +1525,42 @@ export function heartbeatService(db: Db) { context.projectId = executionWorkspace.projectId; } const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; - const previousSessionDisplayId = truncateDisplayId( + let previousSessionDisplayId = truncateDisplayId( taskSessionForRun?.sessionDisplayId ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, ); + let runtimeSessionIdForAdapter = + readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; + let runtimeSessionParamsForAdapter = runtimeSessionParams; + + const sessionCompaction = await evaluateSessionCompaction({ + agent, + sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter, + issueId, + }); + if (sessionCompaction.rotate) { + context.paperclipSessionHandoffMarkdown = sessionCompaction.handoffMarkdown; + context.paperclipSessionRotationReason = sessionCompaction.reason; + context.paperclipPreviousSessionId = previousSessionDisplayId ?? runtimeSessionIdForAdapter; + runtimeSessionIdForAdapter = null; + runtimeSessionParamsForAdapter = null; + previousSessionDisplayId = null; + if (sessionCompaction.reason) { + runtimeWorkspaceWarnings.push( + `Starting a fresh session because ${sessionCompaction.reason}.`, + ); + } + } else { + delete context.paperclipSessionHandoffMarkdown; + delete context.paperclipSessionRotationReason; + delete context.paperclipPreviousSessionId; + } + const runtimeForAdapter = { - sessionId: readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, - sessionParams: runtimeSessionParams, + sessionId: runtimeSessionIdForAdapter, + sessionParams: runtimeSessionParamsForAdapter, sessionDisplayId: previousSessionDisplayId, taskKey, }; @@ -1522,6 +1804,14 @@ export function heartbeatService(db: Db) { previousDisplayId: runtimeForAdapter.sessionDisplayId, previousLegacySessionId: runtimeForAdapter.sessionId, }); + const rawUsage = normalizeUsageTotals(adapterResult.usage); + const sessionUsageResolution = await resolveNormalizedUsageForSession({ + agentId: agent.id, + runId: run.id, + sessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId, + rawUsage, + }); + const normalizedUsage = sessionUsageResolution.normalizedUsage; let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; const latestRun = await getRun(run.id); @@ -1550,9 +1840,23 @@ export function heartbeatService(db: Db) { : "failed"; const usageJson = - adapterResult.usage || adapterResult.costUsd != null + normalizedUsage || adapterResult.costUsd != null ? ({ - ...(adapterResult.usage ?? {}), + ...(normalizedUsage ?? {}), + ...(rawUsage ? { + rawInputTokens: rawUsage.inputTokens, + rawCachedInputTokens: rawUsage.cachedInputTokens, + rawOutputTokens: rawUsage.outputTokens, + } : {}), + ...(sessionUsageResolution.derivedFromSessionTotals ? { usageSource: "session_delta" } : {}), + ...((nextSessionState.displayId ?? nextSessionState.legacySessionId) + ? { persistedSessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId } + : {}), + sessionReused: runtimeForAdapter.sessionId != null || runtimeForAdapter.sessionDisplayId != null, + taskSessionReused: taskSessionForRun != null, + freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null, + sessionRotated: sessionCompaction.rotate, + sessionRotationReason: sessionCompaction.reason, ...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}), ...(adapterResult.billingType ? { billingType: adapterResult.billingType } : {}), } as Record) @@ -1609,7 +1913,7 @@ export function heartbeatService(db: Db) { if (finalizedRun) { await updateRuntimeState(agent, finalizedRun, adapterResult, { legacySessionId: nextSessionState.legacySessionId, - }); + }, normalizedUsage); if (taskKey) { if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) { await clearTaskSessions(agent.companyId, agent.id, { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 807a97eb..d6f5a643 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1060,13 +1060,70 @@ export function issueService(db: Db) { .returning() .then((rows) => rows[0] ?? null), - listComments: (issueId: string) => - db + listComments: async ( + issueId: string, + opts?: { + afterCommentId?: string | null; + order?: "asc" | "desc"; + limit?: number | null; + }, + ) => { + const order = opts?.order === "asc" ? "asc" : "desc"; + const afterCommentId = opts?.afterCommentId?.trim() || null; + const limit = opts?.limit && opts.limit > 0 ? Math.floor(opts.limit) : null; + + const conditions = [eq(issueComments.issueId, issueId)]; + if (afterCommentId) { + const anchor = await db + .select({ + id: issueComments.id, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId))) + .then((rows) => rows[0] ?? null); + + if (!anchor) return []; + conditions.push( + sql`(${issueComments.createdAt} > ${anchor.createdAt} OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} <> ${anchor.id}))`, + ); + } + + const query = db .select() .from(issueComments) + .where(and(...conditions)) + .orderBy(order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt)); + + const comments = limit ? await query.limit(limit) : await query; + return comments.map(redactIssueComment); + }, + + getCommentCursor: async (issueId: string) => { + const latest = await db + .select({ + latestCommentId: issueComments.id, + latestCommentAt: issueComments.createdAt, + }) + .from(issueComments) .where(eq(issueComments.issueId, issueId)) - .orderBy(desc(issueComments.createdAt)) - .then((comments) => comments.map(redactIssueComment)), + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + + const [{ totalComments }] = await db + .select({ + totalComments: sql`count(*)::int`, + }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + return { + totalComments: Number(totalComments ?? 0), + latestCommentId: latest?.latestCommentId ?? null, + latestCommentAt: latest?.latestCommentAt ?? null, + }; + }, getComment: (commentId: string) => db diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index d1858ee6..928e3bc1 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -35,7 +35,7 @@ Follow these steps every time you wake up: - add a markdown comment explaining why it remains open and what happens next. Always include links to the approval and issue in that comment. -**Step 3 — Get assignments.** `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,blocked`. Results sorted by priority. This is your inbox. +**Step 3 — Get assignments.** Prefer `GET /api/agents/me/inbox-lite` for the normal heartbeat inbox. It returns the compact assignment list you need for prioritization. Fall back to `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,blocked` only when you need the full issue objects. **Step 4 — Pick work (with mention exception).** Work on `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it. **Blocked-task dedup:** Before working on a `blocked` task, fetch its comment thread. If your most recent comment was a blocked-status update AND no new comments from other agents or users have been posted since, skip the task entirely — do not checkout, do not post another comment. Exit the heartbeat (or move to the next task) instead. Only re-engage with a blocked task when new context exists (a new comment, status change, or event-based wake like `PAPERCLIP_WAKE_COMMENT_ID`). @@ -56,8 +56,15 @@ Headers: Authorization: Bearer $PAPERCLIP_API_KEY, X-Paperclip-Run-Id: $PAPERCLI If already checked out by you, returns normally. If owned by another agent: `409 Conflict` — stop, pick a different task. **Never retry a 409.** -**Step 6 — Understand context.** `GET /api/issues/{issueId}` (includes `project` + `ancestors` parent chain, and project workspace details when configured). `GET /api/issues/{issueId}/comments`. Read ancestors to understand _why_ this task exists. -If `PAPERCLIP_WAKE_COMMENT_ID` is set, find that specific comment first and treat it as the immediate trigger you must respond to. Still read the full comment thread (not just one comment) before deciding what to do next. +**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay. + +Use comments incrementally: + +- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}` +- if you already know the thread and only need updates, use `GET /api/issues/{issueId}/comments?after={last-seen-comment-id}&order=asc` +- use the full `GET /api/issues/{issueId}/comments` route only when you are cold-starting, when session memory is unreliable, or when the incremental path is not enough + +Read enough ancestor/comment context to understand _why_ the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat. **Step 7 — Do the work.** Use your tools and capabilities. @@ -226,10 +233,13 @@ PATCH /api/agents/{agentId}/instructions-path | Action | Endpoint | | ------------------------------------- | ------------------------------------------------------------------------------------------ | | My identity | `GET /api/agents/me` | +| My compact inbox | `GET /api/agents/me/inbox-lite` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | +| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` | | Get comments | `GET /api/issues/:issueId/comments` | +| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` | | Get specific comment | `GET /api/issues/:issueId/comments/:commentId` | | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 5f92a588..abfc04fb 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -444,23 +444,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /> {isLocal && ( - - mark("adapterConfig", "promptTemplate", v ?? "")} - placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." - contentClassName="min-h-[88px] text-sm font-mono" - imageUploadHandler={async (file) => { - const namespace = `agents/${props.agent.id}/prompt-template`; - const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); - return asset.contentPath; - }} - /> - + <> + + mark("adapterConfig", "promptTemplate", v ?? "")} + placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." + contentClassName="min-h-[88px] text-sm font-mono" + imageUploadHandler={async (file) => { + const namespace = `agents/${props.agent.id}/prompt-template`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> + +
+ Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn. +
+ )}
@@ -576,19 +581,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {/* Prompt template (create mode only — edit mode shows this in Identity) */} {isLocal && isCreate && ( - - set!({ promptTemplate: v })} - placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." - contentClassName="min-h-[88px] text-sm font-mono" - imageUploadHandler={async (file) => { - const namespace = "agents/drafts/prompt-template"; - const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); - return asset.contentPath; - }} - /> - + <> + + set!({ promptTemplate: v })} + placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." + contentClassName="min-h-[88px] text-sm font-mono" + imageUploadHandler={async (file) => { + const namespace = "agents/drafts/prompt-template"; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> + +
+ Prompt template is replayed on every heartbeat. Prefer small task framing and variables like {"{{ context.* }}"} or {"{{ run.* }}"}; avoid repeating stable instructions here. +
+ )} {/* Adapter-specific fields */} @@ -704,6 +714,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} /> +
+ Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it. +
{adapterType === "claude_local" && ( )} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 77a5b14c..3384c366 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -26,7 +26,7 @@ export const help: Record = { capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", - promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", + promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.", model: "Override the default model used by the adapter.", thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.", chrome: "Enable Claude's Chrome integration by passing --chrome.", @@ -44,7 +44,7 @@ export const help: Record = { args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", - bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.", + bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.", payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.", webhookUrl: "The URL that receives POST requests when the agent is invoked.", heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.", From 4a368f54d5de458081a352850d99d95fd0200ed1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:37:30 -0500 Subject: [PATCH 16/28] Delay onboarding starter task creation until launch Co-Authored-By: Paperclip --- ui/src/components/OnboardingWizard.tsx | 64 ++++++++++++++------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 5d166929..88e16d09 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -494,23 +494,41 @@ export function OnboardingWizard() { } async function handleStep3Next() { + if (!createdCompanyId || !createdAgentId) return; + setError(null); + setStep(4); + } + + async function handleLaunch() { if (!createdCompanyId || !createdAgentId) return; setLoading(true); setError(null); try { - const issue = await issuesApi.create(createdCompanyId, { - title: taskTitle.trim(), - ...(taskDescription.trim() - ? { description: taskDescription.trim() } - : {}), - assigneeAgentId: createdAgentId, - status: "todo" - }); - setCreatedIssueRef(issue.identifier ?? issue.id); - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.list(createdCompanyId) - }); - setStep(4); + let issueRef = createdIssueRef; + if (!issueRef) { + const issue = await issuesApi.create(createdCompanyId, { + title: taskTitle.trim(), + ...(taskDescription.trim() + ? { description: taskDescription.trim() } + : {}), + assigneeAgentId: createdAgentId, + status: "todo" + }); + issueRef = issue.identifier ?? issue.id; + setCreatedIssueRef(issueRef); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.list(createdCompanyId) + }); + } + + setSelectedCompanyId(createdCompanyId); + reset(); + closeOnboarding(); + navigate( + createdCompanyPrefix + ? `/${createdCompanyPrefix}/issues/${issueRef}` + : `/issues/${issueRef}` + ); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create task"); } finally { @@ -518,20 +536,6 @@ export function OnboardingWizard() { } } - async function handleLaunch() { - if (!createdAgentId) return; - setLoading(true); - setError(null); - setLoading(false); - reset(); - closeOnboarding(); - if (createdCompanyPrefix) { - navigate(`/${createdCompanyPrefix}/dashboard`); - return; - } - navigate("/dashboard"); - } - function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -1175,8 +1179,8 @@ export function OnboardingWizard() {

Ready to launch

- Everything is set up. Your assigned task already woke - the agent, so you can jump straight to the issue. + Everything is set up. Launching now will create the + starter task, wake the agent, and open the issue.

@@ -1291,7 +1295,7 @@ export function OnboardingWizard() { ) : ( )} - {loading ? "Opening..." : "Open Issue"} + {loading ? "Creating..." : "Create & Open Issue"} )} From 2b9de934e3b31656c535d4dbe3092c29c87b6c74 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:04:28 -0500 Subject: [PATCH 17/28] Fix manual company switch route sync Co-Authored-By: Paperclip --- ui/src/components/Layout.tsx | 11 ++++++++- ui/src/context/CompanyContext.tsx | 3 +-- ui/src/lib/company-selection.test.ts | 34 ++++++++++++++++++++++++++++ ui/src/lib/company-selection.ts | 18 +++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/company-selection.test.ts create mode 100644 ui/src/lib/company-selection.ts diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a90efa9a..e484b265 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -22,6 +22,7 @@ import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; +import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; @@ -36,6 +37,7 @@ export function Layout() { loading: companiesLoading, selectedCompany, selectedCompanyId, + selectionSource, setSelectedCompanyId, } = useCompany(); const { theme, toggleTheme } = useTheme(); @@ -88,7 +90,13 @@ export function Layout() { return; } - if (selectedCompanyId !== matchedCompany.id) { + if ( + shouldSyncCompanySelectionFromRoute({ + selectionSource, + selectedCompanyId, + routeCompanyId: matchedCompany.id, + }) + ) { setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ @@ -99,6 +107,7 @@ export function Layout() { location.pathname, location.search, navigate, + selectionSource, selectedCompanyId, setSelectedCompanyId, ]); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index eafc7f55..fb074f33 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared"; import { companiesApi } from "../api/companies"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; - -type CompanySelectionSource = "manual" | "route_sync" | "bootstrap"; +import type { CompanySelectionSource } from "../lib/company-selection"; type CompanySelectionOptions = { source?: CompanySelectionSource }; interface CompanyContextValue { diff --git a/ui/src/lib/company-selection.test.ts b/ui/src/lib/company-selection.test.ts new file mode 100644 index 00000000..a8533a4b --- /dev/null +++ b/ui/src/lib/company-selection.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldSyncCompanySelectionFromRoute } from "./company-selection"; + +describe("shouldSyncCompanySelectionFromRoute", () => { + it("does not resync when selection already matches the route", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "route_sync", + selectedCompanyId: "pap", + routeCompanyId: "pap", + }), + ).toBe(false); + }); + + it("defers route sync while a manual company switch is in flight", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "manual", + selectedCompanyId: "pap", + routeCompanyId: "ret", + }), + ).toBe(false); + }); + + it("syncs back to the route company for non-manual mismatches", () => { + expect( + shouldSyncCompanySelectionFromRoute({ + selectionSource: "route_sync", + selectedCompanyId: "pap", + routeCompanyId: "ret", + }), + ).toBe(true); + }); +}); diff --git a/ui/src/lib/company-selection.ts b/ui/src/lib/company-selection.ts new file mode 100644 index 00000000..ce02cb4d --- /dev/null +++ b/ui/src/lib/company-selection.ts @@ -0,0 +1,18 @@ +export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap"; + +export function shouldSyncCompanySelectionFromRoute(params: { + selectionSource: CompanySelectionSource; + selectedCompanyId: string | null; + routeCompanyId: string; +}): boolean { + const { selectionSource, selectedCompanyId, routeCompanyId } = params; + + if (selectedCompanyId === routeCompanyId) return false; + + // Let manual company switches finish their remembered-path navigation first. + if (selectionSource === "manual" && selectedCompanyId) { + return false; + } + + return true; +} From 6365e03731ba8f6c8d3a6d9f831112248e8f1bb6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:11:37 -0500 Subject: [PATCH 18/28] 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 --- ui/src/components/NewIssueDialog.tsx | 96 ++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 01f210ed..c017306c 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + parseAssigneeValue, +} from "../lib/assignees"; import { Dialog, DialogContent, @@ -63,7 +68,8 @@ interface IssueDraft { description: string; status: string; priority: string; - assigneeId: string; + assigneeValue: string; + assigneeId?: string; projectId: string; assigneeModelOverride: string; assigneeThinkingEffort: string; @@ -173,7 +179,7 @@ export function NewIssueDialog() { const [description, setDescription] = useState(""); const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState(""); - const [assigneeId, setAssigneeId] = useState(""); + const [assigneeValue, setAssigneeValue] = useState(""); const [projectId, setProjectId] = useState(""); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); @@ -220,7 +226,11 @@ export function NewIssueDialog() { userId: currentUserId, }); - const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null; + const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]); + const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; + const selectedAssigneeUserId = selectedAssignee.assigneeUserId; + + const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); @@ -295,7 +305,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -307,7 +317,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -330,7 +340,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -340,7 +350,11 @@ export function NewIssueDialog() { setDescription(draft.description); setStatus(draft.status || "todo"); setPriority(draft.priority); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId); + setAssigneeValue( + newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId + ? assigneeValueFromSelection(newIssueDefaults) + : (draft.assigneeValue ?? draft.assigneeId ?? ""), + ); setProjectId(newIssueDefaults.projectId ?? draft.projectId); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); @@ -350,7 +364,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -390,7 +404,7 @@ export function NewIssueDialog() { setDescription(""); setStatus("todo"); setPriority(""); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeOptionsOpen(false); setAssigneeModelOverride(""); @@ -406,7 +420,7 @@ export function NewIssueDialog() { function handleCompanyChange(companyId: string) { if (companyId === effectiveCompanyId) return; setDialogCompanyId(companyId); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); @@ -443,7 +457,8 @@ export function NewIssueDialog() { description: description.trim() || undefined, status, priority: priority || "medium", - ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), + ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), + ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(projectId ? { projectId } : {}), ...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), @@ -475,7 +490,9 @@ export function NewIssueDialog() { const hasDraft = title.trim().length > 0 || description.trim().length > 0; const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentPriority = priorities.find((p) => p.value === priority); - const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); + const currentAssignee = selectedAssigneeAgentId + ? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId) + : null; const currentProject = orderedProjects.find((project) => project.id === projectId); const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? currentProject?.executionWorkspacePolicy ?? null @@ -497,16 +514,18 @@ export function NewIssueDialog() { : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( - () => - sortAgentsByRecency( + () => [ + ...currentUserAssigneeOption(currentUserId), + ...sortAgentsByRecency( (agents ?? []).filter((agent) => agent.status !== "terminated"), recentAssigneeIds, ).map((agent) => ({ - id: agent.id, + id: assigneeValueFromSelection({ assigneeAgentId: agent.id }), label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), - [agents, recentAssigneeIds], + ], + [agents, currentUserId, recentAssigneeIds], ); const projectOptions = useMemo( () => @@ -710,7 +729,16 @@ export function NewIssueDialog() { } if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); - assigneeSelectorRef.current?.focus(); + if (assigneeValue) { + // Assignee already set — skip to project or description + if (projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } + } else { + assigneeSelectorRef.current?.focus(); + } } }} autoFocus @@ -723,33 +751,49 @@ export function NewIssueDialog() { For { if (id) trackRecentAssignee(id); setAssigneeId(id); }} + onChange={(value) => { + const nextAssignee = parseAssigneeValue(value); + if (nextAssignee.assigneeAgentId) { + trackRecentAssignee(nextAssignee.assigneeAgentId); + } + setAssigneeValue(value); + }} onConfirm={() => { - projectSelectorRef.current?.focus(); + if (projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } }} renderTriggerValue={(option) => - option && currentAssignee ? ( - <> - + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( {option.label} - + ) ) : ( Assignee ) } renderOption={(option) => { if (!option.id) return {option.label}; - const assignee = (agents ?? []).find((agent) => agent.id === option.id); + const assignee = parseAssigneeValue(option.id).assigneeAgentId + ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId) + : null; return ( <> - + {assignee ? : null} {option.label} ); From 32ab4f8e47f55a983f056d6d438ea134ac22237a Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:12:38 -0500 Subject: [PATCH 19/28] Add me and unassigned assignee options Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 29 +++--- ui/src/components/IssuesList.tsx | 123 ++++++++++++++++++++------ ui/src/context/DialogContext.tsx | 1 + ui/src/lib/assignees.test.ts | 53 +++++++++++ ui/src/lib/assignees.ts | 51 +++++++++++ ui/src/pages/IssueDetail.tsx | 3 +- 6 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 ui/src/lib/assignees.test.ts create mode 100644 ui/src/lib/assignees.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ca8e1bd4..cf4b6a43 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; - const userLabel = (userId: string | null | undefined) => - userId - ? userId === "local-board" - ? "Board" - : currentUserId && userId === currentUserId - ? "Me" - : userId.slice(0, 5) - : null; + const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); @@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > No assignee - {issue.createdByUserId && ( + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6899bd5c..442f8ae4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; +import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; @@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } -function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { +function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { let result = issues; if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId)); + if (state.assignees.length > 0) { + result = result.filter((issue) => { + for (const assignee of state.assignees) { + if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; + if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; + if (issue.assigneeAgentId === assignee) return true; + } + return false; + }); + } if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); return result; } @@ -165,6 +176,11 @@ export function IssuesList({ }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -224,9 +240,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState); + const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -253,13 +269,21 @@ export function IssuesList({ .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } // assignee - const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned"); + const groups = groupBy( + filtered, + (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), + ); return Object.keys(groups).map((key) => ({ key, - label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)), + label: + key === "__unassigned" + ? "Unassigned" + : key.startsWith("__user:") + ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") + : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); const newIssueDefaults = (groupKey?: string) => { const defaults: Record = {}; @@ -267,13 +291,16 @@ export function IssuesList({ if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; - else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey; + else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") { + if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); + else defaults.assigneeAgentId = groupKey; + } } return defaults; }; - const assignIssue = (issueId: string, assigneeAgentId: string | null) => { - onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); + const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }; @@ -419,22 +446,37 @@ export function IssuesList({ {/* Assignee */} - {agents && agents.length > 0 && ( -
- Assignee -
- {agents.map((agent) => ( - - ))} -
+
+ Assignee +
+ + {currentUserId && ( + + )} + {(agents ?? []).map((agent) => ( + + ))}
- )} +
{labels && labels.length > 0 && (
@@ -683,6 +725,13 @@ export function IssuesList({ > {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( + ) : issue.assigneeUserId ? ( + + + + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} + ) : ( @@ -701,7 +750,7 @@ export function IssuesList({ > setAssigneeSearch(e.target.value)} autoFocus @@ -710,16 +759,32 @@ export function IssuesList({ + {currentUserId && ( + + )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; @@ -737,7 +802,7 @@ export function IssuesList({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - assignIssue(issue.id, agent.id); + assignIssue(issue.id, agent.id, null); }} > diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index ef7b12b8..904ceb88 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,7 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + assigneeUserId?: string; title?: string; description?: string; } diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts new file mode 100644 index 00000000..1ce22ef7 --- /dev/null +++ b/ui/src/lib/assignees.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + formatAssigneeUserLabel, + parseAssigneeValue, +} from "./assignees"; + +describe("assignee selection helpers", () => { + it("encodes and parses agent assignees", () => { + const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" }); + + expect(value).toBe("agent:agent-123"); + expect(parseAssigneeValue(value)).toEqual({ + assigneeAgentId: "agent-123", + assigneeUserId: null, + }); + }); + + it("encodes and parses current-user assignees", () => { + const [option] = currentUserAssigneeOption("local-board"); + + expect(option).toEqual({ + id: "user:local-board", + label: "Me", + searchText: "me board human local-board", + }); + expect(parseAssigneeValue(option.id)).toEqual({ + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + }); + + it("treats an empty selection as no assignee", () => { + expect(parseAssigneeValue("")).toEqual({ + assigneeAgentId: null, + assigneeUserId: null, + }); + }); + + it("keeps backward compatibility for raw agent ids in saved drafts", () => { + expect(parseAssigneeValue("legacy-agent-id")).toEqual({ + assigneeAgentId: "legacy-agent-id", + assigneeUserId: null, + }); + }); + + it("formats current and board user labels consistently", () => { + expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me"); + expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); + expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts new file mode 100644 index 00000000..274bcd40 --- /dev/null +++ b/ui/src/lib/assignees.ts @@ -0,0 +1,51 @@ +export interface AssigneeSelection { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AssigneeOption { + id: string; + label: string; + searchText?: string; +} + +export function assigneeValueFromSelection(selection: Partial): string { + if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; + if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; + return ""; +} + +export function parseAssigneeValue(value: string): AssigneeSelection { + if (!value) { + return { assigneeAgentId: null, assigneeUserId: null }; + } + if (value.startsWith("agent:")) { + const assigneeAgentId = value.slice("agent:".length); + return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null }; + } + if (value.startsWith("user:")) { + const assigneeUserId = value.slice("user:".length); + return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null }; + } + // Backward compatibility for older drafts/defaults that stored a raw agent id. + return { assigneeAgentId: value, assigneeUserId: null }; +} + +export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] { + if (!currentUserId) return []; + return [{ + id: assigneeValueFromSelection({ assigneeUserId: currentUserId }), + label: "Me", + searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`, + }]; +} + +export function formatAssigneeUserLabel( + userId: string | null | undefined, + currentUserId: string | null | undefined, +): string | null { + if (!userId) return null; + if (currentUserId && userId === currentUserId) return "Me"; + if (userId === "local-board") return "Board"; + return userId.slice(0, 5); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index bb152e17..9a43f26a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -304,8 +304,7 @@ export function IssueDetail() { options.push({ id: `agent:${agent.id}`, label: agent.name }); } if (currentUserId) { - const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; - options.push({ id: `user:${currentUserId}`, label }); + options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; }, [agents, currentUserId]); From 41eb8e51e33610d3b7095abfa2b5adf2c1ff35af Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 09:58:26 -0500 Subject: [PATCH 20/28] Fix company switch remembered routes Co-Authored-By: Paperclip --- ui/src/hooks/useCompanyPageMemory.test.ts | 71 +++++++++++++++++++++++ ui/src/hooks/useCompanyPageMemory.ts | 39 +++++++------ ui/src/lib/company-page-memory.ts | 65 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 ui/src/hooks/useCompanyPageMemory.test.ts create mode 100644 ui/src/lib/company-page-memory.ts diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts new file mode 100644 index 00000000..a64c60b8 --- /dev/null +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + getRememberedPathOwnerCompanyId, + sanitizeRememberedPathForCompany, +} from "../lib/company-page-memory"; + +const companies = [ + { id: "for", issuePrefix: "FOR" }, + { id: "pap", issuePrefix: "PAP" }, +]; + +describe("getRememberedPathOwnerCompanyId", () => { + it("uses the route company instead of stale selected-company state for prefixed routes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/FOR/issues/FOR-1", + fallbackCompanyId: "pap", + }), + ).toBe("for"); + }); + + it("skips saving when a prefixed route cannot yet be resolved to a known company", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies: [], + pathname: "/FOR/issues/FOR-1", + fallbackCompanyId: "pap", + }), + ).toBeNull(); + }); + + it("falls back to the previous company for unprefixed board routes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/dashboard", + fallbackCompanyId: "pap", + }), + ).toBe("pap"); + }); +}); + +describe("sanitizeRememberedPathForCompany", () => { + it("keeps remembered issue paths that belong to the target company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/issues/PAP-12", + companyPrefix: "PAP", + }), + ).toBe("/issues/PAP-12"); + }); + + it("falls back to dashboard for remembered issue identifiers from another company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/issues/FOR-1", + companyPrefix: "PAP", + }), + ).toBe("/dashboard"); + }); + + it("falls back to dashboard when no remembered path exists", () => { + expect( + sanitizeRememberedPathForCompany({ + path: null, + companyPrefix: "PAP", + }), + ).toBe("/dashboard"); + }); +}); diff --git a/ui/src/hooks/useCompanyPageMemory.ts b/ui/src/hooks/useCompanyPageMemory.ts index d427e587..5206df11 100644 --- a/ui/src/hooks/useCompanyPageMemory.ts +++ b/ui/src/hooks/useCompanyPageMemory.ts @@ -1,10 +1,14 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useLocation, useNavigate } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { toCompanyRelativePath } from "../lib/company-routes"; +import { + getRememberedPathOwnerCompanyId, + isRememberableCompanyPath, + sanitizeRememberedPathForCompany, +} from "../lib/company-page-memory"; const STORAGE_KEY = "paperclip.companyPaths"; -const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]); function getCompanyPaths(): Record { try { @@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) { localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); } -function isRememberableCompanyPath(path: string): boolean { - const pathname = path.split("?")[0] ?? ""; - const segments = pathname.split("/").filter(Boolean); - if (segments.length === 0) return true; - const [root] = segments; - if (GLOBAL_SEGMENTS.has(root!)) return false; - return true; -} - /** * Remembers the last visited page per company and navigates to it on company switch. * Falls back to /dashboard if no page was previously visited for a company. */ export function useCompanyPageMemory() { - const { selectedCompanyId, selectedCompany, selectionSource } = useCompany(); + const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany(); const location = useLocation(); const navigate = useNavigate(); const prevCompanyId = useRef(selectedCompanyId); + const rememberedPathOwnerCompanyId = useMemo( + () => + getRememberedPathOwnerCompanyId({ + companies, + pathname: location.pathname, + fallbackCompanyId: prevCompanyId.current, + }), + [companies, location.pathname], + ); // Save current path for current company on every location change. // Uses prevCompanyId ref so we save under the correct company even // during the render where selectedCompanyId has already changed. const fullPath = location.pathname + location.search; useEffect(() => { - const companyId = prevCompanyId.current; + const companyId = rememberedPathOwnerCompanyId; const relativePath = toCompanyRelativePath(fullPath); if (companyId && isRememberableCompanyPath(relativePath)) { saveCompanyPath(companyId, relativePath); } - }, [fullPath]); + }, [fullPath, rememberedPathOwnerCompanyId]); // Navigate to saved path when company changes useEffect(() => { @@ -63,9 +67,10 @@ export function useCompanyPageMemory() { ) { if (selectionSource !== "route_sync" && selectedCompany) { const paths = getCompanyPaths(); - const savedPath = paths[selectedCompanyId]; - const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard"; - const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard"; + const targetPath = sanitizeRememberedPathForCompany({ + path: paths[selectedCompanyId], + companyPrefix: selectedCompany.issuePrefix, + }); navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true }); } } diff --git a/ui/src/lib/company-page-memory.ts b/ui/src/lib/company-page-memory.ts new file mode 100644 index 00000000..df549b68 --- /dev/null +++ b/ui/src/lib/company-page-memory.ts @@ -0,0 +1,65 @@ +import { + extractCompanyPrefixFromPath, + normalizeCompanyPrefix, + toCompanyRelativePath, +} from "./company-routes"; + +const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]); + +export function isRememberableCompanyPath(path: string): boolean { + const pathname = path.split("?")[0] ?? ""; + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) return true; + const [root] = segments; + if (GLOBAL_SEGMENTS.has(root!)) return false; + return true; +} + +function findCompanyByPrefix(params: { + companies: T[]; + companyPrefix: string; +}): T | null { + const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix); + return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null; +} + +export function getRememberedPathOwnerCompanyId(params: { + companies: T[]; + pathname: string; + fallbackCompanyId: string | null; +}): string | null { + const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname); + if (!routeCompanyPrefix) { + return params.fallbackCompanyId; + } + + return findCompanyByPrefix({ + companies: params.companies, + companyPrefix: routeCompanyPrefix, + })?.id ?? null; +} + +export function sanitizeRememberedPathForCompany(params: { + path: string | null | undefined; + companyPrefix: string; +}): string { + const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard"; + if (!isRememberableCompanyPath(relativePath)) { + return "/dashboard"; + } + + const pathname = relativePath.split("?")[0] ?? ""; + const segments = pathname.split("/").filter(Boolean); + const [root, entityId] = segments; + if (root === "issues" && entityId) { + const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId); + if ( + identifierMatch && + normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix) + ) { + return "/dashboard"; + } + } + + return relativePath; +} From d51c4b1a4c378449ee39c33956c7e31e08e3a231 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 10:18:00 -0500 Subject: [PATCH 21/28] fix: tighten token optimization edge cases Co-Authored-By: Paperclip --- server/src/routes/issues.ts | 7 ++++++- server/src/services/heartbeat.ts | 21 +++++++++++++++++++-- server/src/services/issues.ts | 21 ++++++++++++++++++--- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bc938910..0213ded2 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -28,6 +28,8 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; +const MAX_ISSUE_COMMENT_LIMIT = 500; + export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = issueService(db); @@ -878,7 +880,10 @@ export function issueRoutes(db: Db, storage: StorageService) { typeof req.query.limit === "string" && req.query.limit.trim().length > 0 ? Number(req.query.limit) : null; - const limit = limitRaw && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : null; + const limit = + limitRaw && Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT) + : null; const comments = await svc.listComments(id, { afterCommentId, order, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1a9dba74..eec83d08 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -623,6 +623,19 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getOldestRunForSession(agentId: string, sessionId: string) { + return db + .select({ + id: heartbeatRuns.id, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.sessionIdAfter, sessionId))) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + async function resolveNormalizedUsageForSession(input: { agentId: string; runId: string; @@ -672,6 +685,7 @@ export function heartbeatService(db: Db) { }; } + const fetchLimit = Math.max(policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0, 4); const runs = await db .select({ id: heartbeatRuns.id, @@ -683,7 +697,7 @@ export function heartbeatService(db: Db) { .from(heartbeatRuns) .where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId))) .orderBy(desc(heartbeatRuns.createdAt)) - .limit(Math.max(policy.maxSessionRuns + 1, 4)); + .limit(fetchLimit); if (runs.length === 0) { return { @@ -695,7 +709,10 @@ export function heartbeatService(db: Db) { } const latestRun = runs[0] ?? null; - const oldestRun = runs[runs.length - 1] ?? latestRun; + const oldestRun = + policy.maxSessionAgeHours > 0 + ? await getOldestRunForSession(agent.id, sessionId) + : runs[runs.length - 1] ?? latestRun; const latestRawUsage = readRawUsageTotals(latestRun?.usageJson); const sessionAgeHours = latestRun && oldestRun diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index d6f5a643..c42ac02a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -27,6 +27,7 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac import { getDefaultCompanyGoal } from "./goals.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; +const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; function assertTransition(from: string, to: string) { if (from === to) return; @@ -1070,7 +1071,10 @@ export function issueService(db: Db) { ) => { const order = opts?.order === "asc" ? "asc" : "desc"; const afterCommentId = opts?.afterCommentId?.trim() || null; - const limit = opts?.limit && opts.limit > 0 ? Math.floor(opts.limit) : null; + const limit = + opts?.limit && opts.limit > 0 + ? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT) + : null; const conditions = [eq(issueComments.issueId, issueId)]; if (afterCommentId) { @@ -1085,7 +1089,15 @@ export function issueService(db: Db) { if (!anchor) return []; conditions.push( - sql`(${issueComments.createdAt} > ${anchor.createdAt} OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} <> ${anchor.id}))`, + order === "asc" + ? sql`( + ${issueComments.createdAt} > ${anchor.createdAt} + OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id}) + )` + : sql`( + ${issueComments.createdAt} < ${anchor.createdAt} + OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id}) + )`, ); } @@ -1093,7 +1105,10 @@ export function issueService(db: Db) { .select() .from(issueComments) .where(and(...conditions)) - .orderBy(order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt)); + .orderBy( + order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt), + order === "asc" ? asc(issueComments.id) : desc(issueComments.id), + ); const comments = limit ? await query.limit(limit) : await query; return comments.map(redactIssueComment); From cce9941464b4107b66ba02918f62689a54954a8d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 11:12:43 -0500 Subject: [PATCH 22/28] Add worktree UI branding --- cli/src/__tests__/worktree.test.ts | 17 +- cli/src/commands/worktree-lib.ts | 58 ++++- cli/src/commands/worktree.ts | 12 +- doc/DEVELOPING.md | 8 +- server/src/__tests__/ui-branding.test.ts | 66 +++++- server/src/ui-branding.ts | 208 +++++++++++++++-- ui/index.html | 2 + ui/src/components/Layout.tsx | 274 ++++++++++++----------- ui/src/components/WorktreeBanner.tsx | 25 +++ ui/src/lib/worktree-branding.ts | 65 ++++++ 10 files changed, 566 insertions(+), 169 deletions(-) create mode 100644 ui/src/components/WorktreeBanner.tsx create mode 100644 ui/src/lib/worktree-branding.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 115d03b3..fe325cd2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -16,6 +16,7 @@ import { buildWorktreeConfig, buildWorktreeEnvEntries, formatShellExports, + generateWorktreeColor, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, rewriteLocalUrlPort, @@ -181,13 +182,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 +290,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) { diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4a0a3aeb..5249acc2 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -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 { +export function buildWorktreeEnvEntries( + paths: WorktreeLocalPaths, + branding?: WorktreeUiBranding, +): Record { 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 } : {}), }; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 582bb5dd..fca320b9 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -39,6 +39,7 @@ import { buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, + generateWorktreeColor, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, @@ -623,7 +624,7 @@ async function seedWorktreeDatabase(input: { async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); - const name = resolveSuggestedWorktreeName( + const worktreeName = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); @@ -631,12 +632,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { 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 +674,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); mergePaperclipEnvEntries( { - ...buildWorktreeEnvEntries(paths), + ...buildWorktreeEnvEntries(paths, branding), ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), }, paths.envPath, @@ -710,6 +715,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { 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( diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1ca1409b..4b379dcb 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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=` +- `PAPERCLIP_WORKTREE_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: diff --git a/server/src/__tests__/ui-branding.test.ts b/server/src/__tests__/ui-branding.test.ts index 858823bb..c649d5c9 100644 --- a/server/src/__tests__/ui-branding.test.ts +++ b/server/src/__tests__/ui-branding.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; -import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js"; +import { + applyUiBranding, + getWorktreeUiBranding, + isWorktreeUiBrandingEnabled, + renderFaviconLinks, + renderRuntimeBrandingMeta, +} from "../ui-branding.js"; const TEMPLATE = ` + + @@ -18,21 +26,57 @@ describe("ui branding", () => { expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false); }); - it("renders the worktree favicon asset set when enabled", () => { - const links = renderFaviconLinks(true); - expect(links).toContain("/worktree-favicon.ico"); - expect(links).toContain("/worktree-favicon.svg"); - expect(links).toContain("/worktree-favicon-32x32.png"); - expect(links).toContain("/worktree-favicon-16x16.png"); + it("resolves name, color, and text color for worktree branding", () => { + const branding = getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + + expect(branding.enabled).toBe(true); + expect(branding.name).toBe("paperclip-pr-432"); + expect(branding.color).toBe("#4f86f7"); + expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/); + expect(branding.faviconHref).toContain("data:image/svg+xml,"); }); - it("rewrites the favicon block for worktree instances only", () => { - const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" }); - expect(branded).toContain("/worktree-favicon.svg"); + it("renders a dynamic worktree favicon when enabled", () => { + const links = renderFaviconLinks( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(links).toContain("data:image/svg+xml,"); + expect(links).toContain('rel="shortcut icon"'); + }); + + it("renders runtime branding metadata for the ui", () => { + const meta = renderRuntimeBrandingMeta( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(meta).toContain('name="paperclip-worktree-name"'); + expect(meta).toContain('content="paperclip-pr-432"'); + expect(meta).toContain('name="paperclip-worktree-color"'); + }); + + it("rewrites the favicon and runtime branding blocks for worktree instances only", () => { + const branded = applyUiBranding(TEMPLATE, { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + expect(branded).toContain("data:image/svg+xml,"); + expect(branded).toContain('name="paperclip-worktree-name"'); expect(branded).not.toContain('href="/favicon.svg"'); const defaultHtml = applyUiBranding(TEMPLATE, {}); expect(defaultHtml).toContain('href="/favicon.svg"'); - expect(defaultHtml).not.toContain("/worktree-favicon.svg"); + expect(defaultHtml).not.toContain('name="paperclip-worktree-name"'); }); }); diff --git a/server/src/ui-branding.ts b/server/src/ui-branding.ts index bb6f3a33..8195c91e 100644 --- a/server/src/ui-branding.ts +++ b/server/src/ui-branding.ts @@ -1,5 +1,7 @@ const FAVICON_BLOCK_START = ""; const FAVICON_BLOCK_END = ""; +const RUNTIME_BRANDING_BLOCK_START = ""; +const RUNTIME_BRANDING_BLOCK_END = ""; const DEFAULT_FAVICON_LINKS = [ '', @@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [ '', ].join("\n"); -const WORKTREE_FAVICON_LINKS = [ - '', - '', - '', - '', -].join("\n"); +export type WorktreeUiBranding = { + enabled: boolean; + name: string | null; + color: string | null; + textColor: string | null; + faviconHref: string | null; +}; function isTruthyEnvValue(value: string | undefined): boolean { if (!value) return false; @@ -21,21 +24,194 @@ function isTruthyEnvValue(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } +function nonEmpty(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeHexColor(value: string | undefined): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + const hex = raw.startsWith("#") ? raw.slice(1) : raw; + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`; + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return `#${hex.toLowerCase()}`; + } + return null; +} + +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)}`; +} + +function deriveColorFromSeed(seed: string): string { + let hash = 0; + for (const char of seed) { + hash = ((hash * 33) + char.charCodeAt(0)) >>> 0; + } + return hslToHex(hash % 360, 68, 56); +} + +function hexToRgb(color: string): { r: number; g: number; b: number } { + const normalized = normalizeHexColor(color) ?? "#000000"; + return { + r: Number.parseInt(normalized.slice(1, 3), 16), + g: Number.parseInt(normalized.slice(3, 5), 16), + b: Number.parseInt(normalized.slice(5, 7), 16), + }; +} + +function relativeLuminanceChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(color: string): number { + const { r, g, b } = hexToRgb(color); + return ( + (0.2126 * relativeLuminanceChannel(r)) + + (0.7152 * relativeLuminanceChannel(g)) + + (0.0722 * relativeLuminanceChannel(b)) + ); +} + +function pickReadableTextColor(background: string): string { + const backgroundLuminance = relativeLuminance(background); + const whiteContrast = 1.05 / (backgroundLuminance + 0.05); + const blackContrast = (backgroundLuminance + 0.05) / 0.05; + return whiteContrast >= blackContrast ? "#f8fafc" : "#111827"; +} + +function escapeHtmlAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function createFaviconDataUrl(background: string, foreground: string): string { + const svg = [ + '', + ``, + ``, + "", + ].join(""); + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE); } -export function renderFaviconLinks(worktree: boolean): string { - return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS; +export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): WorktreeUiBranding { + if (!isWorktreeUiBrandingEnabled(env)) { + return { + enabled: false, + name: null, + color: null, + textColor: null, + faviconHref: null, + }; + } + + const name = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? "worktree"; + const color = normalizeHexColor(env.PAPERCLIP_WORKTREE_COLOR) ?? deriveColorFromSeed(name); + const textColor = pickReadableTextColor(color); + + return { + enabled: true, + name, + color, + textColor, + faviconHref: createFaviconDataUrl(color, textColor), + }; +} + +export function renderFaviconLinks(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.faviconHref) return DEFAULT_FAVICON_LINKS; + + const href = escapeHtmlAttribute(branding.faviconHref); + return [ + ``, + ``, + ].join("\n"); +} + +export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return ""; + + return [ + '', + ``, + ``, + ``, + ].join("\n"); +} + +function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string { + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker); + if (start === -1 || end === -1 || end < start) return html; + + const before = html.slice(0, start + startMarker.length); + const after = html.slice(end); + const indentedContent = content + ? `\n${content + .split("\n") + .map((line) => ` ${line}`) + .join("\n")}\n ` + : "\n "; + return `${before}${indentedContent}${after}`; } export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string { - const start = html.indexOf(FAVICON_BLOCK_START); - const end = html.indexOf(FAVICON_BLOCK_END); - if (start === -1 || end === -1 || end < start) return html; - - const before = html.slice(0, start + FAVICON_BLOCK_START.length); - const after = html.slice(end); - const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env)); - return `${before}\n${links}\n ${after}`; + const branding = getWorktreeUiBranding(env); + const withFavicon = replaceMarkedBlock(html, FAVICON_BLOCK_START, FAVICON_BLOCK_END, renderFaviconLinks(branding)); + return replaceMarkedBlock( + withFavicon, + RUNTIME_BRANDING_BLOCK_START, + RUNTIME_BRANDING_BLOCK_END, + renderRuntimeBrandingMeta(branding), + ); } diff --git a/ui/index.html b/ui/index.html index 7994c0d2..1bb9152e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,8 @@ Paperclip + + diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e484b265..43094b51 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -14,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; +import { WorktreeBanner } from "./WorktreeBanner"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -223,7 +224,7 @@ export function Layout() {
Skip to Main Content - {/* Mobile backdrop */} - {isMobile && sidebarOpen && ( - - -
-
-
- ) : ( -
-
- -
- {isInstanceSettingsRoute ? : } -
-
-
-
- - - Documentation - - - -
-
-
- )} - - {/* Main content */} -
-
- -
-
-
- {hasUnknownCompanyPrefix ? ( - - ) : ( - +
+ + {isInstanceSettingsRoute ? : } +
+
+
+ + + Documentation + + + +
+
+
+ ) : ( +
+
+ +
+ {isInstanceSettingsRoute ? : } +
+
+
+
+ + + Documentation + + + +
+
+
+ )} + + {/* Main content */} +
+
- + > + +
+
+
+ {hasUnknownCompanyPrefix ? ( + + ) : ( + + )} +
+ +
{isMobile && } diff --git a/ui/src/components/WorktreeBanner.tsx b/ui/src/components/WorktreeBanner.tsx new file mode 100644 index 00000000..6808b2da --- /dev/null +++ b/ui/src/components/WorktreeBanner.tsx @@ -0,0 +1,25 @@ +import { getWorktreeUiBranding } from "../lib/worktree-branding"; + +export function WorktreeBanner() { + const branding = getWorktreeUiBranding(); + if (!branding) return null; + + return ( +
+
+ Worktree +
+
+ ); +} diff --git a/ui/src/lib/worktree-branding.ts b/ui/src/lib/worktree-branding.ts new file mode 100644 index 00000000..6f6d8dc4 --- /dev/null +++ b/ui/src/lib/worktree-branding.ts @@ -0,0 +1,65 @@ +export type WorktreeUiBranding = { + enabled: true; + name: string; + color: string; + textColor: string; +}; + +function readMetaContent(name: string): string | null { + if (typeof document === "undefined") return null; + const element = document.querySelector(`meta[name="${name}"]`); + const content = element?.getAttribute("content")?.trim(); + return content ? content : null; +} + +function normalizeHexColor(value: string | null): string | null { + if (!value) return null; + const hex = value.startsWith("#") ? value.slice(1) : value; + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`; + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return `#${hex.toLowerCase()}`; + } + return null; +} + +function hexToRgb(color: string): { r: number; g: number; b: number } { + const normalized = normalizeHexColor(color) ?? "#000000"; + return { + r: Number.parseInt(normalized.slice(1, 3), 16), + g: Number.parseInt(normalized.slice(3, 5), 16), + b: Number.parseInt(normalized.slice(5, 7), 16), + }; +} + +function relativeLuminanceChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function pickReadableTextColor(background: string): string { + const { r, g, b } = hexToRgb(background); + const luminance = + (0.2126 * relativeLuminanceChannel(r)) + + (0.7152 * relativeLuminanceChannel(g)) + + (0.0722 * relativeLuminanceChannel(b)); + const whiteContrast = 1.05 / (luminance + 0.05); + const blackContrast = (luminance + 0.05) / 0.05; + return whiteContrast >= blackContrast ? "#f8fafc" : "#111827"; +} + +export function getWorktreeUiBranding(): WorktreeUiBranding | null { + if (readMetaContent("paperclip-worktree-enabled") !== "true") return null; + + const name = readMetaContent("paperclip-worktree-name"); + const color = normalizeHexColor(readMetaContent("paperclip-worktree-color")); + if (!name || !color) return null; + + return { + enabled: true, + name, + color, + textColor: normalizeHexColor(readMetaContent("paperclip-worktree-text-color")) ?? pickReadableTextColor(color), + }; +} From 528505a04a1d7acfe341709403a2b5b50458bb4c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 11:53:56 -0500 Subject: [PATCH 23/28] fix: isolate codex home in worktrees --- .../2026-03-13-TOKEN-OPTIMIZATION-PLAN.md | 14 ++ docs/adapters/codex-local.md | 2 + .../codex-local/src/server/codex-home.ts | 101 +++++++++ .../codex-local/src/server/execute.ts | 80 ++++++- ...08-46-token-optimization-implementation.md | 8 + .../src/__tests__/codex-local-execute.test.ts | 208 ++++++++++++++++++ .../codex-local-skill-injection.test.ts | 91 ++++++++ 7 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 packages/adapters/codex-local/src/server/codex-home.ts create mode 100644 server/src/__tests__/codex-local-execute.test.ts create mode 100644 server/src/__tests__/codex-local-skill-injection.test.ts diff --git a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md index e85cfdfc..7053e97f 100644 --- a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md +++ b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md @@ -118,6 +118,14 @@ 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 @@ -215,6 +223,8 @@ This is the right version of the discussion’s 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 diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index 60725a49..ad187f75 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -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 diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts new file mode 100644 index 00000000..a182e998 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -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; +} + +async function pathExists(candidate: string): Promise { + 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 { + await fs.mkdir(path.dirname(target), { recursive: true }); +} + +async function ensureSymlink(target: string, source: string): Promise { + 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 { + 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 { + 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; +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 1bccbc44..416f0e8e 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -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"; @@ -22,6 +21,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +import { prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -61,10 +61,36 @@ function resolveCodexBillingType(env: Record): "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 pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function isLikelyPaperclipRepoRoot(candidate: string): Promise { + 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 { + 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 = { @@ -80,7 +106,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, @@ -97,6 +123,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; @@ -161,12 +212,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 = { ...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()) || diff --git a/report/2026-03-13-08-46-token-optimization-implementation.md b/report/2026-03-13-08-46-token-optimization-implementation.md index 9bee83ca..7cfd5ad6 100644 --- a/report/2026-03-13-08-46-token-optimization-implementation.md +++ b/report/2026-03-13-08-46-token-optimization-implementation.md @@ -14,6 +14,14 @@ The main behavior changes are: - The agent config UI now explains the difference between bootstrap prompts and heartbeat prompts and warns about prompt churn. - Runtime skill defaults now include `paperclip`, `para-memory-files`, and `paperclip-create-agent`. `create-agent-adapter` was moved to `.agents/skills/create-agent-adapter`. +Important follow-up finding from real-run review: + +- `codex_local` currently injects Paperclip skills into the shared Codex skills home (`$CODEX_HOME/skills` or `~/.codex/skills`) rather than mounting a worktree-local skill directory. +- If a Paperclip-owned skill symlink already points at another live checkout, the adapter currently skips it instead of repointing it. +- In practice, this means a worktree can contain newer `skills/paperclip/SKILL.md` guidance while Codex still follows an older checkout's skill content. +- That likely explains why PAP-507 still showed full issue/comment reload behavior even though the incremental context work was already implemented in this branch. +- This should be treated as a separate follow-up item for `codex_local` skill isolation or symlink repair. + Files with the most important implementation work: - `server/src/services/heartbeat.ts` diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts new file mode 100644 index 00000000..1dfcb3b7 --- /dev/null +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execute } from "@paperclipai/adapter-codex-local/server"; + +async function writeFakeCodexCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + codexHome: process.env.CODEX_HOME || null, + paperclipEnvKeys: Object.keys(process.env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(), +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "thread.started", thread_id: "codex-session-1" })); +console.log(JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } })); +console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +type CapturePayload = { + argv: string[]; + prompt: string; + codexHome: string | null; + paperclipEnvKeys: string[]; +}; + +describe("codex execute", () => { + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const isolatedCodexHome = path.join(paperclipHome, "instances", "worktree-1", "codex-home"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8"); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE; + const previousCodexHome = process.env.CODEX_HOME; + process.env.HOME = root; + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "worktree-1"; + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.CODEX_HOME = sharedCodexHome; + + try { + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.codexHome).toBe(isolatedCodexHome); + expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"])); + expect(capture.prompt).toContain("Follow the paperclip heartbeat."); + expect(capture.paperclipEnvKeys).toEqual( + expect.arrayContaining([ + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_API_KEY", + "PAPERCLIP_API_URL", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_RUN_ID", + ]), + ); + + const isolatedAuth = path.join(isolatedCodexHome, "auth.json"); + const isolatedConfig = path.join(isolatedCodexHome, "config.toml"); + const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip"); + + expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json"))); + expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true); + expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n'); + expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId; + if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE; + else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("respects an explicit CODEX_HOME config override even in worktree mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const explicitCodexHome = path.join(root, "explicit-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE; + const previousCodexHome = process.env.CODEX_HOME; + process.env.HOME = root; + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "worktree-1"; + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.CODEX_HOME = sharedCodexHome; + + try { + const result = await execute({ + runId: "run-2", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + CODEX_HOME: explicitCodexHome, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.codexHome).toBe(explicitCodexHome); + await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId; + if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE; + else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/codex-local-skill-injection.test.ts b/server/src/__tests__/codex-local-skill-injection.test.ts new file mode 100644 index 00000000..bbbaec63 --- /dev/null +++ b/server/src/__tests__/codex-local-skill-injection.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function createPaperclipRepoSkill(root: string, skillName: string) { + await fs.mkdir(path.join(root, "server"), { recursive: true }); + await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true }); + await fs.mkdir(path.join(root, "skills", skillName), { recursive: true }); + await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8"); + await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8"); + await fs.writeFile( + path.join(root, "skills", skillName, "SKILL.md"), + `---\nname: ${skillName}\n---\n`, + "utf8", + ); +} + +async function createCustomSkill(root: string, skillName: string) { + await fs.mkdir(path.join(root, "custom", skillName), { recursive: true }); + await fs.writeFile( + path.join(root, "custom", skillName, "SKILL.md"), + `---\nname: ${skillName}\n---\n`, + "utf8", + ); +} + +describe("codex local adapter skill injection", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("repairs a Codex Paperclip skill symlink that still points at another live checkout", async () => { + const currentRepo = await makeTempDir("paperclip-codex-current-"); + const oldRepo = await makeTempDir("paperclip-codex-old-"); + const skillsHome = await makeTempDir("paperclip-codex-home-"); + cleanupDirs.add(currentRepo); + cleanupDirs.add(oldRepo); + cleanupDirs.add(skillsHome); + + await createPaperclipRepoSkill(currentRepo, "paperclip"); + await createPaperclipRepoSkill(oldRepo, "paperclip"); + await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip")); + + const logs: string[] = []; + await ensureCodexSkillsInjected( + async (_stream, chunk) => { + logs.push(chunk); + }, + { + skillsHome, + skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }], + }, + ); + + expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe( + await fs.realpath(path.join(currentRepo, "skills", "paperclip")), + ); + expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true); + }); + + it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => { + const currentRepo = await makeTempDir("paperclip-codex-current-"); + const customRoot = await makeTempDir("paperclip-codex-custom-"); + const skillsHome = await makeTempDir("paperclip-codex-home-"); + cleanupDirs.add(currentRepo); + cleanupDirs.add(customRoot); + cleanupDirs.add(skillsHome); + + await createPaperclipRepoSkill(currentRepo, "paperclip"); + await createCustomSkill(customRoot, "paperclip"); + await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip")); + + await ensureCodexSkillsInjected(async () => {}, { + skillsHome, + skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }], + }); + + expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe( + await fs.realpath(path.join(customRoot, "custom", "paperclip")), + ); + }); +}); From c1430e7b06e57b4c40efe0758052f236607528ef Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 14:37:44 -0500 Subject: [PATCH 24/28] docs: add paperclip skill tightening plan Co-Authored-By: Paperclip --- ...6-03-13-paperclip-skill-tightening-plan.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 doc/plans/2026-03-13-paperclip-skill-tightening-plan.md diff --git a/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md new file mode 100644 index 00000000..68d4ad3c --- /dev/null +++ b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md @@ -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 `` 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 From a393db78b40cac03ccc4eeba470e8c5edd21971d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 14:53:30 -0500 Subject: [PATCH 25/28] fix: address greptile follow-up Co-Authored-By: Paperclip --- .../codex-local/src/server/codex-home.ts | 2 +- .../codex-local/src/server/execute.ts | 6 +-- packages/shared/src/validators/agent.ts | 5 ++- server/src/services/issues.ts | 38 ++++++++++--------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index a182e998..de037d6a 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -11,7 +11,7 @@ function nonEmpty(value: string | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -async function pathExists(candidate: string): Promise { +export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 416f0e8e..d4b3da46 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -21,7 +21,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; -import { prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; +import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -61,10 +61,6 @@ function resolveCodexBillingType(env: Record): "api" | "subscrip return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } -async function pathExists(candidate: string): Promise { - return fs.access(candidate).then(() => true).catch(() => false); -} - async function isLikelyPaperclipRepoRoot(candidate: string): Promise { const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([ pathExists(path.join(candidate, "pnpm-workspace.yaml")), diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index dae54754..f703f036 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -78,7 +78,10 @@ export const wakeAgentSchema = z.object({ reason: z.string().optional().nullable(), payload: z.record(z.unknown()).optional().nullable(), idempotencyKey: z.string().optional().nullable(), - forceFreshSession: z.boolean().optional().default(false), + forceFreshSession: z.preprocess( + (value) => (value === null ? undefined : value), + z.boolean().optional().default(false), + ), }); export type WakeAgent = z.infer; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index c42ac02a..ecff7c3c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1115,26 +1115,28 @@ export function issueService(db: Db) { }, getCommentCursor: async (issueId: string) => { - const latest = await db - .select({ - latestCommentId: issueComments.id, - latestCommentAt: issueComments.createdAt, - }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)) - .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - - const [{ totalComments }] = await db - .select({ - totalComments: sql`count(*)::int`, - }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const [latest, countRow] = await Promise.all([ + db + .select({ + latestCommentId: issueComments.id, + latestCommentAt: issueComments.createdAt, + }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)) + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select({ + totalComments: sql`count(*)::int`, + }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)) + .then((rows) => rows[0] ?? null), + ]); return { - totalComments: Number(totalComments ?? 0), + totalComments: Number(countRow?.totalComments ?? 0), latestCommentId: latest?.latestCommentId ?? null, latestCommentAt: latest?.latestCommentAt ?? null, }; From aa799bba4cd22f698d85ece17a94bd58add9ee9e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 14:24:06 -0500 Subject: [PATCH 26/28] Fix worktree seed source selection Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 54 ++++++++++++++++++++++++++++++ cli/src/commands/worktree.ts | 9 ++++- doc/DEVELOPING.md | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index fe325cd2..b6e2eb47 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -7,6 +7,7 @@ import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd, + resolveSourceConfigPath, resolveGitWorktreeAddArgs, resolveWorktreeMakeTargetPath, worktreeInitCommand, @@ -305,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({ diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index fca320b9..b77317fd 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -56,6 +56,7 @@ type WorktreeInitOptions = { fromConfig?: string; fromDataDir?: string; fromInstance?: string; + sourceConfigPathOverride?: string; serverPort?: number; dbPort?: number; seed?: boolean; @@ -426,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"); @@ -751,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}`); @@ -810,6 +816,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt await runWorktreeInit({ ...opts, name, + sourceConfigPathOverride: sourceConfigPath, }); } catch (error) { throw error; diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 4b379dcb..e3668516 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -142,7 +142,7 @@ This command: - creates an isolated instance under `~/.paperclip-worktrees/instances//` - 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: From 626a8f1976c7638bf23e35a8b0fb58f317492ff5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 15:07:49 -0500 Subject: [PATCH 27/28] fix(cli): quote env values with special characters --- cli/src/__tests__/agent-jwt-env.test.ts | 18 ++++++++++++++++++ cli/src/__tests__/worktree.test.ts | 2 +- cli/src/config/env.ts | 9 ++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 40bb1554..baf5db51 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -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"); + }); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index b6e2eb47..a8333ba5 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -294,7 +294,7 @@ describe("worktree helpers", () => { 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}/); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); } finally { process.chdir(originalCwd); if (originalJwtSecret === undefined) { diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 4bc8f16e..a7266ea2 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -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) { 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"); From db81a0638671e1b0f95d4011b45c505dd0d485bf Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 15:07:56 -0500 Subject: [PATCH 28/28] docs: add agent evals framework plan --- doc/plans/2026-03-13-agent-evals-framework.md | 775 ++++++++++++++++++ 1 file changed, 775 insertions(+) create mode 100644 doc/plans/2026-03-13-agent-evals-framework.md diff --git a/doc/plans/2026-03-13-agent-evals-framework.md b/doc/plans/2026-03-13-agent-evals-framework.md new file mode 100644 index 00000000..6c4cc55e --- /dev/null +++ b/doc/plans/2026-03-13-agent-evals-framework.md @@ -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: +- OpenAI eval best practices: +- Promptfoo docs: and +- LangSmith complex agent eval docs: +- Braintrust dataset/scorer docs: and + +## 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 Paperclip’s 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 + +OpenAI’s guidance is directionally right: + +- eval early and often +- use task-specific evals +- log everything +- prefer pairwise/comparison-style judging over open-ended scoring + +But OpenAI’s 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 agent’s 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; + 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; +}; +``` + +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 + +OpenAI’s 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 OpenAI’s 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 Paperclip’s actual control-plane behaviors.