From f3c18db7dddab726fa8837544fc9b279e4f2b225 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:37:30 -0500 Subject: [PATCH 1/9] 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 c9259bbec0d201eb864a7058c7d1fef0114c28c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:04:28 -0500 Subject: [PATCH 2/9] 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 3/9] 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 4/9] 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 5/9] 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 d51c4b1a4c378449ee39c33956c7e31e08e3a231 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 10:18:00 -0500 Subject: [PATCH 6/9] 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 528505a04a1d7acfe341709403a2b5b50458bb4c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 11:53:56 -0500 Subject: [PATCH 7/9] 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 8/9] 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 9/9] 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, };