Compare commits

...

197 commits

Author SHA1 Message Date
f00a30ca21 [nexus] fix: restore upstream delegation and fact-extraction content lost during rebase 2026-04-01 07:54:09 +02:00
e05bf13d52 [nexus] chore: regenerate pnpm-lock.yaml after upstream rebase 2026-04-01 07:46:41 +02:00
bb7d84e34a feat(12-02): SkillDetail Ratings tab and Overview usage stats
- Add Ratings tab (4th tab) with rate form, personal history, community section
- Overview tab: conditionally render taskCount, avgCostUsd, lastUsedAt rows
- Import StarRating, Separator, Skeleton, Textarea, EmptyState, TooltipProvider
- saveRatingMutation calls addRating, invalidates ratings query on success
- ratingsQuery loads personal history with loading/empty/error states
2026-04-01 07:46:09 +02:00
6a79c8b004 feat(12-02): StarRating component, API extensions, DesignGuide entry
- Create StarRating component with interactive/readonly modes, amber stars, size sm/md
- Add PersonalRating type and taskCount/avgCostUsd/lastUsedAt to SkillListItem
- Add getRatings and addRating to skillRegistryApi
- Add Rating System section to DesignGuide with all variants
- Fix SkillCard fixture and DesignGuide examples to include new SkillListItem fields
2026-04-01 07:46:09 +02:00
2bb998a7b2 feat(12-01): ratings routes, community ratings in fetcher, list/getById JOIN, heartbeat hook
- Add POST/GET /skill-registry/skills/:sourceId/:slug/ratings routes
- Import skillRatingService in skill-registry routes
- Add upsertCommunityRatingsStub() in fetcher, called after each skill upsert
- Import communityRatings from schema in fetcher
- Update list() and getById() in skill-registry.ts to LEFT JOIN communityRatings
- Include averageRating, ratingCount, taskCount, avgCostUsd, lastUsedAt in SkillListItem
- Add agentSkills usage aggregation via LEFT JOIN + SUM/AVG/MAX
- Add fire-and-forget recordUsageForAgent call in heartbeat after finalizeAgentStatus
- Dynamic import keeps skill-registry-ratings off critical startup path
- All 44 skill-registry tests pass, full server suite (536) green
2026-04-01 07:46:09 +02:00
b38a230b77 feat(12-01): personalRatings schema, DB DDL, skillRatingService, and tests
- Add personalRatings table to skill-registry-schema.ts
- Add taskCount, avgCostUsd, lastUsedAt columns to agentSkills in schema
- Add CREATE_PERSONAL_RATINGS_TABLE DDL constant in skill-registry-db.ts
- Add ALTER TABLE statements for new agent_skills usage columns (idempotent)
- Create skill-registry-ratings.ts with skillRatingService factory
- rate() appends personal rating, validates stars 1-5
- getRatings() returns ratings ordered by createdAt DESC
- recordUsageForAgent() atomically updates task_count, avg_cost_usd, last_used_at
- All 8 tests pass
2026-04-01 07:46:09 +02:00
27237c32ee fix(11): default agentSkillsDir server-side — GROUP-03/GROUP-04 gap closure 2026-04-01 07:46:09 +02:00
e495d98e48 feat(11-04): extend AgentSkillsTab with groups section and dialogs
- Add imports: skillGroupsApi, SkillGroupRow, GroupBadge, Dialog, Separator, ScrollArea, Textarea
- Add agentGroups + allGroups + agentEffectiveSkills queries in AgentSkillsTab
- Add assignGroup + removeGroup + createGroup mutations
- Add state for add/create/remove dialogs, search, new group fields
- Insert Assigned Groups section with loading/empty/populated states
- Insert Combined Effective Skills collapsible section with ScrollArea
- Insert Additional Individual Skills label above existing skill list
- Add Add Skill Group, Create Skill Group, Remove group from agent dialogs
- Add Skill Groups section to DesignGuide with all GroupBadge variants
2026-04-01 07:46:09 +02:00
cb8423c400 feat(11-04): create GroupBadge component
- Built-in variant: Badge secondary, no dismiss, hover:bg-accent/30
- Custom variant: Badge outline, X dismiss button with spinner, hover:bg-accent/50
- Tooltip on both variants showing name · built-in · N skills or name · N skills
- text-sm font-semibold typography per UI-SPEC (no font-bold or font-medium)
2026-04-01 07:46:09 +02:00
63d303b32b feat(11-03): add skill groups frontend API client and query keys
- Create ui/src/api/skillGroups.ts with skillGroupsApi object
- All 14 methods covering group CRUD, members, export/import, agent assignments
- removeGroup uses raw fetch for DELETE-with-body (api.delete has no body support)
- Add skillGroups namespace to ui/src/lib/queryKeys.ts with 6 key factories
2026-04-01 07:46:09 +02:00
930d8cb114 feat(11-03): add skill group routes, mount in app.ts, startup reconciliation
- Create server/src/routes/skill-registry-groups.ts with skillGroupRoutes() factory
- All 14 REST routes for group CRUD, members, export/import, and agent assignments
- Import route registered before :groupId param to avoid route collision
- assertBoard on every handler, error classification (400/404/409/500)
- Mount skillGroupRoutes() in app.ts after skillRegistryRoutes()
- Add pendingSkillGroups fire-and-forget reconciliation in index.ts startup
2026-04-01 07:46:09 +02:00
b918c5e809 feat(11-02): skillGroupService() with CRUD, membership, inheritance, assignment, import/export
- Group CRUD: listGroups, getGroup, createGroup, updateGroup, deleteGroup (guards built-in)
- Member management: addMember, removeMember, listMembers
- Inheritance: addParent (with cycle detection BFS), removeParent, listParents
- resolveEffectiveSkills: BFS walk with visited-set guard for cycle safety
- assignGroup: installs all effective skills, tracks in agent_skills, returns installed/skipped/pendingPlugin
- removeGroup: set-difference uninstall with fs.rm() for file removal (not skillRegistryService.uninstall)
- listAgentGroups, listAgentSkills, getAgentEffectiveSkills
- exportGroup / importGroup: GroupExport v1 JSON with cycle check on import
2026-04-01 07:46:09 +02:00
43abc69fb5 feat(11-01): add group table DDL and built-in group seeding to skill-registry-db
- Import LibSQLClient type for seedBuiltinGroups parameter typing
- Add DDL constants for 5 new tables: skill_groups, skill_group_members,
  skill_group_inheritance, agent_skill_groups, agent_skills
- Add BUILTIN_GROUPS constant with 5 entries (pm-essentials, engineer-core,
  frontend, backend, creative)
- Add seedBuiltinGroups() using INSERT OR IGNORE for idempotent seeding
- Extend getSkillRegistryDb() to execute all 5 new DDL statements and seed
2026-04-01 07:46:09 +02:00
2a5bfc16e8 feat(11-01): add five Drizzle table definitions for skill groups
- Add primaryKey import from drizzle-orm/sqlite-core
- Add skillGroups table with id, name, description, isBuiltin, timestamps
- Add skillGroupMembers junction table with composite PK (groupId, skillId)
- Add skillGroupInheritance table with composite PK (childGroupId, parentGroupId)
- Add agentSkillGroups table with composite PK (agentId, groupId)
- Add agentSkills table with composite PK (agentId, skillId)
2026-04-01 07:46:09 +02:00
328e44b887 fix(10): correct toast error messages in SkillDetail — Update/Uninstall not Install/Rollback 2026-04-01 07:46:09 +02:00
530b94b2f7 feat(10-03): update DesignGuide SkillCard section with all 5 required variants
- Expand grid to lg:grid-cols-3 for 5-card layout
- Add Loading (updating) variant (5th card per UI-SPEC requirement)
- Add onRollback/onUninstall props to installed variants
- Add descriptive comments for each variant state
2026-04-01 07:46:09 +02:00
c6eaa25f28 feat(10-03): implement SkillDetail page with Overview, Versions, Diff tabs
- Full SkillDetail.tsx replacing stub (Overview, Versions, Diff tabs)
- Separate installMutation and updateMutation with distinct toast messages
- VersionDiff component using diffLines from diff package
- Version selector with Select dropdowns in Diff tab
- ScrollArea for version list in Versions tab
- Install/Update/Uninstall dialogs with confirmation
- PageSkeleton variant=detail for loading state
- SkillDetail.test.tsx with 4 SSR smoke tests (all passing)
- diff + @types/diff packages installed in ui workspace
2026-04-01 07:46:09 +02:00
daef2c24cb test(10-02): add SkillBrowser SSR smoke tests
- 4 node-environment tests using renderToStaticMarkup
- Verifies page title, search input, refresh button, and tab labels render
- Mocks react-query, router, context providers for SSR compatibility
2026-04-01 07:46:05 +02:00
7ad2671252 feat(10-02): build SkillBrowser page with Browse tab, dialogs, and 5 mutations
- Replace stub with full three-tab page (Browse, Installed, Trending placeholders)
- Five separate mutations: fetch, install, update, rollback, remove with distinct toasts
- Agent selector dialog with skills directory input for install/update flow
- Uninstall confirmation dialog with destructive button
- Browse tab: search, source filter, category filter, sort, FilterBar, card grid, EmptyState
- Breadcrumbs wired via useBreadcrumbs, data via TanStack Query
2026-04-01 07:46:05 +02:00
eabddd3d9a feat(10-01): add SkillCard component, tests, route wiring, and design guide entry
- Create SkillCard.tsx with 3-row layout per UI-SPEC (name link, description, source/rating/actions)
- Create SkillCard.test.tsx with 8 passing unit tests using renderToStaticMarkup
- Create SkillBrowser.tsx and SkillDetail.tsx stub pages
- Update App.tsx: remove CompanySkills import, add skills and skills/detail/:skillId routes
- Add SkillCard to DesignGuide.tsx with 4 variants (default, installed, update available, loading)
2026-04-01 07:46:05 +02:00
2616f0e3fd feat(10-01): add skillRegistry API client and query keys
- Create ui/src/api/skillRegistry.ts with typed methods for all 7 endpoints
- Use two-segment URL paths (/sourceId/slug) for Express 5 compatibility
- Add skillRegistry namespace to queryKeys.ts (list/detail/versions)
2026-04-01 07:46:05 +02:00
dce7662b97 feat(09-04): implement skill registry REST routes and mount in app.ts (GREEN)
- Create skillRegistryRoutes() factory with 6 endpoints
- GET /api/skill-registry/skills (list, includeRemoved support)
- GET /api/skill-registry/skills/:sourceId/:slug (single skill, 404 on missing)
- GET /api/skill-registry/skills/:sourceId/:slug/versions (version history)
- POST /api/skill-registry/fetch (trigger fetchAllSources)
- POST /api/skill-registry/skills/:sourceId/:slug/install (copy to agent dir)
- POST /api/skill-registry/skills/:sourceId/:slug/rollback (restore prior version)
- DELETE /api/skill-registry/skills/:sourceId/:slug (soft-delete)
- assertBoard auth guard on every route
- Mount skillRegistryRoutes() in app.ts after companySkillRoutes
- Update tests to use two-segment path params (sourceId/slug) for Express 5 compatibility
- All 12 route tests pass
2026-04-01 07:46:05 +02:00
380f561963 test(09-04): add failing tests for skill registry routes (RED)
- Tests for GET /api/skill-registry/skills (list, includeRemoved param)
- Tests for GET /api/skill-registry/skills/:id (found, 404)
- Tests for GET /api/skill-registry/skills/:id/versions
- Tests for POST /api/skill-registry/fetch
- Tests for POST /api/skill-registry/skills/:id/install (success, 400)
- Tests for POST /api/skill-registry/skills/:id/rollback (success, 400)
- Tests for DELETE /api/skill-registry/skills/:id
2026-04-01 07:46:05 +02:00
7c9393bd93 feat(09-03): wire skillRegistryService export and startup DB init
- Add skillRegistryService re-export to services/index.ts after companySkillService
- Add fire-and-forget skill registry DB init in server/src/index.ts after reconcile block
- Uses dynamic import to avoid adding libSQL to critical startup path
2026-04-01 07:46:05 +02:00
232728e86c feat(09-03): implement skillRegistryService with install, uninstall, rollback, list
- install() copies cached files to agent .claude/skills/<slug>/ dir
- install() returns pending_plugin_install for skills with file kind=plugin
- uninstall() soft-deletes via removed_at timestamp
- rollback() restores prior version from cache and updates active_version_id
- list() filters soft-deleted by default; includeRemoved=true returns all
- fetchAll() delegates to fetchAllSources for multi-source refresh
2026-04-01 07:46:05 +02:00
aa5c2afb4f test(09-03): add failing tests for skillRegistryService install/rollback/uninstall/list 2026-04-01 07:46:05 +02:00
52fa55293d feat(09-02): implement multi-source skill fetcher with file caching
- SkillSourceConfig type + BUILT_IN_SOURCES (3 sources: anthropic, schwepps, daymade)
- fetchAllSources() fetches from anthropic-marketplace and github-tree source types
- parseSkillFrontmatter() extracts name/description from SKILL.md YAML blocks
- Idempotency: checks version exists before fetching, skips re-download on same SHA
- Caches SKILL.md to skills/cache/<skill-id>/<sha>/SKILL.md on disk
- Inserts skills, skill_versions, and skill_files rows into registry.db
- All 7 tests passing (TDD GREEN)
2026-04-01 07:46:05 +02:00
b79fc29d1a test(09-02): add failing tests for skill-registry-fetcher
- 7 tests covering fetch from Anthropic marketplace and GitHub tree sources
- Tests DB insertion, file caching, idempotency, and BUILT_IN_SOURCES config
- All tests fail with ERR_MODULE_NOT_FOUND (expected TDD RED state)
2026-04-01 07:46:05 +02:00
4ed3cce0a1 feat(09-01): extract GitHub fetch helpers to shared module
- Create github-skill-helpers.ts with fetchText, fetchJson, resolveGitHubDefaultBranch, resolveGitHubCommitSha, parseGitHubSourceUrl, resolveGitHubPinnedRef, resolveRawGitHubUrl
- Update company-skills.ts to import from github-skill-helpers.js instead of defining locally
- All existing company-skill tests pass (15/15)
2026-04-01 07:46:05 +02:00
dcba49af7f feat(09-01): install @libsql/client, schema, DB init, path helpers
- Install @libsql/client@^0.17.2 to server package
- Create skill-registry-schema.ts with 4 sqliteTable definitions (skills, skillVersions, skillFiles, communityRatings)
- Create skill-registry-db.ts with lazy singleton getSkillRegistryDb() and resetSkillRegistryDb()
- Add resolveSkillRegistryDbPath() and resolveSkillCacheDir() to home-paths.ts
- Add skill-registry-schema.test.ts with 8 passing tests (TDD green)
2026-04-01 07:46:05 +02:00
3e88a0fb9a feat(08-02): add ensureGeneralistAgents startup migration for existing workspaces
- Import agentService and agents table into server/src/index.ts
- ensureGeneralistAgents() queries all companies, skips any that already have
  a general-role agent (idempotent), creates Generalist via agentService.create()
- metadata includes pendingSkillGroups: [Creative] and backfilled: true flag
- Called with fire-and-forget void pattern after ensureLocalTrustedBoardPrincipal
- Existing workspaces get Generalist on next server upgrade without user action
2026-04-01 07:46:05 +02:00
1ac1e082f9 fix(08-02): update agent-skills-routes test expectations for rewritten bundles
[Rule 1 - Bug] Tests expected old upstream bundle content strings (You are the CEO.,
CEO Heartbeat Checklist, CEO Persona, Keep the work moving until it is done.)
but Phase 08-01 rewrote all CEO and engineer bundles with PM-focused content.
Updated assertions to match actual bundle output.
2026-04-01 07:46:05 +02:00
b00f36701f feat(08-02): add Generalist agent creation to onboarding wizard
- Third agentsApi.create() call with role: "general", name: "Generalist"
- metadata: { pendingSkillGroups: ["Creative"] } records Phase 11 intent
- Updated description text: mentions all 3 agents (PM, engineer, generalist)
- Placed BEFORE queryClient.invalidateQueries for clean ordering
2026-04-01 07:46:05 +02:00
64db17535f feat(08-01): update PM routing rules to include Generalist delegation
- Add 'Copy, branding, research, legal, docs, presentations -> Generalist agent' routing rule
- Inserted between Engineer and Cross-functional rules in Delegation section
2026-04-01 07:46:05 +02:00
7dc4a15bd3 feat(08-01): add Generalist agent template bundle and wire role mapping
- Create server/src/onboarding-assets/general/ with 4 files (AGENTS.md, SOUL.md, HEARTBEAT.md, TOOLS.md)
- Add general role to DEFAULT_AGENT_BUNDLE_FILES with full 4-file bundle
- Add resolveDefaultAgentInstructionsBundleRole branch for general role
- Rename AGENT_ROLE_LABELS general from 'General' to 'Generalist'
2026-04-01 07:46:05 +02:00
6808b68230 [nexus] fix: replace radix DialogPortal with createPortal in NexusOnboardingWizard 2026-04-01 07:46:05 +02:00
c065a21f1b feat(07-02): update Layout toggle to cycle three themes with next-theme label
- Add THEME_CYCLE map for mocha->tokyo-night->latte->mocha
- Compute nextThemeLabel for descriptive aria-label/title on toggle button
- Update both desktop and mobile toggle button aria-label/title to 'Switch to [theme]'
- Icon logic unchanged: Sun in dark mode, Moon in light mode
2026-04-01 07:46:05 +02:00
076a29dc61 feat(07-02): add theme picker section to InstanceGeneralSettings
- Import useTheme, THEME_META, type Theme from ThemeContext
- Add ORDERED_THEMES constant with three theme IDs
- Add theme picker section as first section in General Settings
- Color swatches use inline backgroundColor (hardcoded hex, not CSS vars)
- Active theme highlighted with border-primary bg-primary/10
2026-04-01 07:46:05 +02:00
868eb8a0ba feat(07-01): update index.html flash-prevention script for three themes
- Update theme-color meta tag default from #18181b to #1e1e2e (Catppuccin Mocha)
- Replace binary dark/light script with three-theme handler
- Toggles .dark and .theme-tokyo-night classes before React mounts
- Falls back to catppuccin-mocha for unknown/old localStorage values
- Removes old #18181b hardcoded color constant
2026-04-01 07:46:05 +02:00
1fbd0a8609 feat(07-01): extend ThemeContext to support three named themes with THEME_META export
- Expand Theme type to catppuccin-mocha | tokyo-night | catppuccin-latte
- Export THEME_META with label, dark boolean, bg hex, primary hex per theme
- applyTheme toggles .dark and .theme-tokyo-night classes correctly
- toggleTheme cycles all three themes (Mocha -> Tokyo Night -> Latte -> Mocha)
- readStoredTheme falls back to catppuccin-mocha for old localStorage values
- Fix Layout.tsx: replace theme === 'dark' comparison with THEME_META[theme].dark
- Fix MarkdownBody.tsx: replace theme === 'dark' comparisons with THEME_META[theme].dark
2026-04-01 07:45:51 +02:00
6ea341615a feat(07-01): replace CSS variable blocks with Catppuccin Mocha, Tokyo Night, and Catppuccin Latte palettes
- Replace :root block with Catppuccin Latte light theme values (#eff1f5 base)
- Replace .dark block with Catppuccin Mocha dark theme values (#1e1e2e base)
- Add .theme-tokyo-night.dark block with Tokyo Night values (#1a1b26 base)
- Remove redundant color-scheme: dark; from scrollbar section (moved into .dark block)
2026-04-01 07:45:51 +02:00
6a21bf852f [nexus] fix(06): resolve verifier gaps — portability fallback, export readme, CLI company descriptions, server error msg 2026-04-01 07:45:51 +02:00
c5e38e5f5e feat(06-03): TERM-18 grep audit — fix remaining display-zone corporate strings
- ui/src/App.tsx: Create/first company titles and descriptions → VOCAB.company
- ui/src/components/OnboardingWizard.tsx: 3 company display strings → VOCAB
- ui/src/components/Sidebar.tsx: 'Select company' fallback → VOCAB
- ui/src/pages/CliAuth.tsx: 'Requested company' label → VOCAB
- ui/src/pages/AgentDetail.tsx: company library string → VOCAB
- server/src/services/company-portability.ts: 'Imported Company' x2 → 'Imported Workspace'
- cli/src/commands/client/{issue,approval,agent,dashboard,activity}.ts: option descriptions → VOCAB
- cli/src/commands/worktree.ts: error message and option description → VOCAB
- server/src/index.ts: comment cleanup (actual value already 'Owner')
- server/src/services/company-export-readme.ts: comment cleanup (value already 'Project Manager')
2026-04-01 07:45:51 +02:00
d1e0c720a7 feat(06-02): replace Select a company empty states + CLI Paperclip strings
- 14 UI pages: all Select a company empty states use VOCAB.company.toLowerCase()
- AgentConfigForm: 3 error throws use VOCAB.company
- AgentDetail: additional Select a company upload error replaced
- CLI run.ts: Starting/Could not locate/failed to start messages use VOCAB.appName
- CLI deployment-auth-check: repairHint uses VOCAB.appName
- CLI agent-jwt-secret-check: repairHint uses VOCAB.appName
- CLI allowed-hostname: restart message uses VOCAB.appName
- Added VOCAB import to all files missing it
2026-04-01 07:45:51 +02:00
f7cbd2074f feat(06-02): replace Paperclip brand + CEO display strings in UI components
- AgentDetail: 10 strings replaced (Paperclip→VOCAB.appName, CEO→VOCAB.ceo, board approval→owner approval)
- RoutineDetail: 8 error messages + select company + secret banner replaced
- DesignGuide: 3 strings replaced (Paperclip, Paperclip App, CEO Agent)
- agent-config-primitives: 3 tooltip strings replaced
- AccountingModelCard, JsonSchemaForm, ProjectProperties, OnboardingWizard: 1 each
- openclaw-gateway/config-fields: 2 strings replaced
- Added VOCAB import to all files missing it
2026-04-01 07:45:23 +02:00
ccc8ead357 feat(06-01): fix named terminology straggler requirements (TERM-10 through TERM-17)
- TERM-10: Companies.tsx breadcrumb uses VOCAB.companies, loading/delete text uses VOCAB
- TERM-11: InstanceSettings.tsx adds VOCAB import, uses VOCAB.company/companies
- TERM-12: Costs.tsx adds VOCAB import and SCOPE_LABELS map, replaces hardcoded company strings
- TERM-13: CompanyImport.tsx uses VOCAB.appName, VOCAB.company, VOCAB.board throughout
- TERM-17: IssuesList.tsx (component) title='Board view' -> 'Kanban view'
- Dashboard.tsx: 'awaiting board review' -> 'awaiting owner review'
- CompanySettings.tsx: 'No company selected' uses VOCAB.company
- ReportsToPicker.tsx: adds VOCAB import, default label uses VOCAB.ceo not hardcoded 'CEO'
2026-04-01 07:45:08 +02:00
f4df47089a test(05-01): rewrite onboarding E2E for Nexus single-step wizard
- Replace 4-step upstream flow test with single-step Nexus wizard test
- Assert h1 'Welcome to Nexus' is visible (ONBD-10/ONBD-11)
- Assert no 'Next' button, no 4-step h3 headings (ONBD-11)
- Assert 'Acme Corp', 'Company name', corporate strings absent (ONBD-12)
- Fill root dir input, click 'Get Started', expect /dashboard/ URL
- Verify 'Project Manager' and 'Engineer' agents created via API
2026-04-01 07:45:08 +02:00
48642dc511 fix(05-01): switch Vite alias to array syntax with RegExp find pattern
- Replace object alias syntax with array of {find, replacement} entries
- '@' and 'lexical' aliases preserved as string find entries
- OnboardingWizard alias uses RegExp /^\.\/components\/OnboardingWizard$/ find
- RegExp matches raw import specifier from App.tsx in both dev and prod modes
2026-04-01 07:45:08 +02:00
b5be048168 [nexus] fix(audit): resolve integration checker findings — straggler strings, query param pre-fill, orphaned import 2026-04-01 07:44:56 +02:00
e6ba3cc9ff [nexus] fix(04-03): add root directory prompt to CLI onboarding (ONBD-06) 2026-04-01 07:44:56 +02:00
988cd117e9 feat(04-02): add Vite alias to redirect OnboardingWizard to NexusOnboardingWizard
- Alias uses absolute path (path.resolve) for correct Vite import resolution
- [nexus] comment marks the change for rebase visibility
- Original OnboardingWizard.tsx and App.tsx remain unmodified
2026-04-01 07:44:56 +02:00
9d916604a3 feat(04-03): add Nexus agent bootstrap to CLI onboarding
- Add bootstrapNexusAgents function with health-check poll (max 30s)
- Create workspace (company) then PM agent (role:ceo) and Engineer agent
- Idempotent: skips if workspace already exists
- Bootstrap runs concurrently before runCommand starts server
- Failures are warnings, not errors
- [nexus] comments on all new lines
2026-04-01 07:44:56 +02:00
54d1c005e7 feat(04-02): create NexusOnboardingWizard component
- Single-step wizard: root directory input only (no company name, mission, or first task)
- Creates workspace named VOCAB.appName (Nexus)
- Creates PM agent (role: ceo, for elevated permissions) + Engineer agent
- Navigates to dashboard after completion, not issue detail
- Preserves resolveRouteOnboardingOptions wizard-show detection logic
- Exports OnboardingWizard to match named import in App.tsx
- Original OnboardingWizard.tsx untouched for upstream rebase compatibility
2026-04-01 07:44:56 +02:00
ffc68ebbf2 feat(04-03): add PM and Engineer template selector to NewAgentDialog
- Add AGENT_TEMPLATES const with Project Manager (role:pm) and Engineer options
- Add template selector section between Ask PM button and advanced config link
- handleTemplateSelect navigates to /agents/new pre-filled with template values
- No hire language present in dialog
- [nexus] marked all new/changed lines
2026-04-01 07:44:56 +02:00
1827c2bf79 feat(04-01): register pm and engineer bundles in bundle registry
- Add pm and engineer entries to DEFAULT_AGENT_BUNDLE_FILES
- Update resolveDefaultAgentInstructionsBundleRole to handle pm and engineer roles
- DefaultAgentBundleRole type auto-includes new keys via keyof typeof
- All changes marked with // [nexus] for rebase visibility
2026-04-01 07:44:56 +02:00
dce9896bc4 feat(04-01): create PM and Engineer agent template bundles, rewrite CEO bundle
- Add server/src/onboarding-assets/pm/ with SOUL.md, AGENTS.md, HEARTBEAT.md, TOOLS.md
- Add server/src/onboarding-assets/engineer/ with SOUL.md, AGENTS.md, HEARTBEAT.md, TOOLS.md
- Rewrite server/src/onboarding-assets/ceo/ as PM-appropriate content with Nexus vocabulary
- All files use workspace/agent/Owner/Project Manager terminology
- Zero Paperclip, CEO, Hire, or Fire references in any template content
2026-04-01 07:44:56 +02:00
fdf9906fdc [nexus] fix(03-05): replace remaining Paperclip/Companies display strings in BreadcrumbContext and CompanySwitcher 2026-04-01 07:44:38 +02:00
13789ff967 fix(03-05): grep audit fixes — CEO→Project Manager in export readme, Board→Owner in local user, test assertion updates
- company-export-readme.ts: ROLE_LABELS ceo changed from 'CEO' to 'Project Manager' [nexus]
- server/index.ts: LOCAL_BOARD_USER_NAME changed from 'Board' to 'Owner' [nexus]
- cli/__tests__/company.test.ts: assertions updated to Workspace vocabulary
- cli/__tests__/http.test.ts: assertion updated to 'Nexus API' from 'Paperclip API'
- ui/OnboardingWizard.tsx: added explicit string type annotation for useState<string>
2026-04-01 07:44:38 +02:00
e7b90c81a2 feat(03-03): replace display strings in page files I-R and App.tsx with VOCAB
- InviteLanding: skill bootstrap and invite heading use VOCAB.appName and VOCAB.company
- IssueDetail: Board actor identity uses VOCAB.board
- NewAgent: first agent name/title defaults to VOCAB.ceo
- NotFound: company not found message uses VOCAB.company
- PluginManager: breadcrumb fallback uses VOCAB.company
- PluginSettings: breadcrumb fallback uses VOCAB.company
- Routines: error messages and creation hint use VOCAB.appName
- App: startup log messages use VOCAB.appName; CLI command unchanged
2026-04-01 07:44:38 +02:00
39b96f8d93 feat(03-04): replace display strings in CLI commands with VOCAB constants
- onboard.ts: intro banner -> 'nexus onboard'; command refs -> nexus; CEO -> VOCAB.ceo
- company.ts: label, description, bold text use VOCAB.company; .command('company') unchanged
- board-auth.ts: 'Board authentication required' uses VOCAB.board
- auth-bootstrap-ceo.ts: 'CEO' references use VOCAB.ceo; 'Paperclip' uses VOCAB.appName
2026-04-01 07:44:20 +02:00
a230f7d9f5 feat(03-03): replace display strings in page files A-D with VOCAB constants
- AgentDetail: hire verb uses VOCAB.hire
- ApprovalDetail: Board identity uses VOCAB.board
- CliAuth: appName and board uses VOCAB; client fallback uses 'nexus cli'
- Companies: button labels use VOCAB.company
- CompanyExport: CEO role label, README text, export header use VOCAB
- CompanySettings: breadcrumb, Staffing section, approval labels, OpenClaw template use VOCAB
- CompanySkills: paperclip skill source label uses VOCAB.appName
- Dashboard: welcome and select messages use VOCAB.appName and VOCAB.company
- Approvals: VOCAB imported (no string changes needed)
2026-04-01 07:44:20 +02:00
89b2827477 feat(03-02): replace display strings in OnboardingWizard, LiveUpdatesProvider, and assignees lib
- OnboardingWizard.tsx: DEFAULT_TASK_DESCRIPTION uses VOCAB.ceo/company/hire; useState uses VOCAB.ceo; task title updated to Nexus vocabulary; step tab label uses VOCAB.company; placeholder uses VOCAB.ceo; launch summary uses VOCAB.company
- LiveUpdatesProvider.tsx: resolveActorLabel returns VOCAB.board instead of hardcoded 'Board'
- assignees.ts: formatAssigneeUserLabel returns VOCAB.board for local-board user
- assignees.test.ts: updated expectation to 'Owner' (VOCAB.board value)
2026-04-01 07:44:02 +02:00
b65bf6dda5 feat(03-04): replace Paperclip display strings in CLI entry point and HTTP client
- Add VOCAB import to cli/src/index.ts and cli/src/client/http.ts
- Replace all 'Paperclip' description/help strings with VOCAB.appName
- Update backup filename prefix default from 'paperclip' to 'nexus'
- Update data dir help text to reference ~/.nexus
- Keep .name('paperclipai') binary name unchanged (CODE-zone)
2026-04-01 07:44:02 +02:00
2680554b44 feat(03-02): replace display strings in UI components with VOCAB constants
- Sidebar.tsx: section label uses VOCAB.company instead of hardcoded 'Company'
- CompanySwitcher.tsx: uses VOCAB.company for placeholder and settings link
- ActivityRow.tsx: uses VOCAB.board instead of hardcoded 'Board' for user actor
- ApprovalPayload.tsx: hire_agent and approve_ceo_strategy values use VOCAB constants
- NewAgentDialog.tsx: CEO references use VOCAB.ceo
- NewGoalDialog.tsx: company level label uses VOCAB.company
2026-04-01 07:44:02 +02:00
ff72ed4143 feat(03-01): replace Paperclip icon with Box in CompanyRail, use VOCAB in Auth
- CompanyRail: import Box from lucide-react instead of Paperclip
- CompanyRail: render <Box> icon instead of <Paperclip> in top rail
- Auth.tsx: import VOCAB from @paperclipai/branding
- Auth.tsx: use VOCAB.appName for logo text and sign-in/create-account headings
2026-04-01 07:44:02 +02:00
748e0ee4e6 feat(03-01): add branding dep and replace HTML/asset branding with Nexus
- Add @paperclipai/branding workspace dep to ui/package.json and cli/package.json
- Change <title> and apple-mobile-web-app-title to Nexus in ui/index.html
- Replace site.webmanifest name/short_name with Nexus
- Replace paperclip SVG favicon with N-letter Nexus favicon
2026-04-01 07:44:02 +02:00
8ecae05b7d feat(02-01): replace PAPERCLIP ASCII art with NEXUS in banners
- Replace PAPERCLIP art with NEXUS art in server/src/startup-banner.ts
- Replace full cli/src/utils/banner.ts with NEXUS art and updated tagline
- Rename printPaperclipCliBanner to printNexusCliBanner
- Update tagline to 'Open-source orchestration for your agents'
- Update all 5 CLI command callers: onboard, configure, db-backup, worktree, doctor
- Satisfies BRND-02
2026-04-01 07:44:02 +02:00
4b670431f3 feat(02-02): update resolveDefaultAgentWorkspaceDir to use slugified agent names
- Change signature from (agentId: string) to (agent: { id: string; name?: string | null })
- Use sanitizeFriendlyPathSegment(name) for human-readable workspace dirs
- Fall back to sanitized id when name is empty/null
- Update all 4 call sites in heartbeat.ts with { id, name } objects
- Add agentName field to resolveRuntimeSessionParamsForWorkspace input type
- Update both test call sites in heartbeat-workspace-session.test.ts
2026-04-01 07:44:02 +02:00
1afc6f427d feat(02-02): add ~/.nexus pointer-file resolution to server and CLI home-paths
- Add resolveNexusPointerFile() helper to server/src/home-paths.ts
- Add resolveNexusPointerFile() helper to cli/src/config/home.ts
- Patch resolvePaperclipHomeDir() in both files: ~/.nexus > PAPERCLIP_HOME > ~/.paperclip
- Add import fs from node:fs to both files
2026-04-01 07:44:02 +02:00
ee17e9470a feat(02-01): update AGENT_ROLE_LABELS.ceo to Project Manager
- Changed ceo: "CEO" to ceo: "Project Manager" in shared constants
- Added [nexus] comment for rebase visibility
- Satisfies TERM-05
2026-04-01 07:44:02 +02:00
902aea524d [nexus] chore(01-02): make install-hooks.sh executable 2026-04-01 07:44:02 +02:00
3bf5e729fc feat(01-foundation-01): register branding package in root vitest config
- Add "packages/branding" to root vitest.config.ts projects array
- Enables pnpm vitest run --project "@paperclipai/branding" from repo root
2026-04-01 07:44:02 +02:00
bc9fd5d81b [nexus] chore(01-02): install commit-msg hook and enable git rerere
- Add scripts/nexus-commit-msg-hook.sh (tracked source for hook)
- Install hook at .git/hooks/commit-msg (executable)
- Enable git rerere with autoupdate for automated conflict re-resolution
2026-04-01 07:43:45 +02:00
31b9fc1639 feat(01-foundation-01): scaffold branding package with VOCAB constant and tests
- Create packages/branding/ workspace package (@paperclipai/branding)
- Add VOCAB constant with 8 Nexus display strings (company, companies, ceo, board, hire, fire, appName, tagline)
- Export VocabKey type for type-safe string lookups
- Add vitest config and 9 passing unit tests covering all VOCAB values
- Update pnpm-lock.yaml to link new workspace package
2026-04-01 07:43:45 +02:00
8e3fda82e8 [nexus] docs(01-02): create zone taxonomy, rebase runbook, and hook installer
- Add .planning/ZONE-TAXONOMY.md classifying all rename targets (DISPLAY/CODE/STORED)
- Add .planning/REBASE-RUNBOOK.md documenting range-diff rebase verification workflow
- Add scripts/install-hooks.sh for post-clone hook reinstallation
2026-04-01 07:43:45 +02:00
Dotta
5b479652f2
Merge pull request #2327 from radiusred/fix/env-var-plain-to-secret-data-loss
fix(ui): preserve env var when switching type from Plain to Secret
2026-03-31 11:37:07 -05:00
Cody (Radius Red)
92e03ac4e3 fix(ui): prevent dropdown snap-back when switching env var to Secret
Address Greptile review feedback: the plain-value fallback in emit()
caused the useEffect sync to re-run toRows(), which mapped the plain
binding back to source: "plain", snapping the dropdown back.

Fix: add an emittingRef that distinguishes local emit() calls from
external value changes (like overlay reset after save). When the
change originated from our own emit, skip the re-sync so the
transitioning row stays in "secret" mode while the user picks a secret.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 15:52:46 +00:00
Cody (Radius Red)
ce8d9eb323 fix(server): preserve adapter-agnostic keys when changing adapter type
When the adapter type changes via PATCH, the server only preserved
instruction bundle keys (instructionsBundleMode, etc.) from the
existing config. Adapter-agnostic keys like env, cwd, timeoutSec,
graceSec, promptTemplate, and bootstrapPromptTemplate were silently
dropped if the PATCH payload didn't explicitly include them.

This caused env var data loss when adapter type was changed via the
UI or API without sending the full existing adapterConfig.

The fix preserves these adapter-agnostic keys from the existing config
before applying the instruction bundle preservation, matching the
UI's behavior in AgentConfigForm.handleSave.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 15:42:03 +00:00
Cody (Radius Red)
06cf00129f fix(ui): preserve env var when switching type from Plain to Secret
When changing an env var's type from Plain to Secret in the agent
config form, the row was silently dropped because emit() skipped
secret rows without a secretId. This caused data loss — the variable
disappeared from both the UI and the saved config.

Fix: keep the row as a plain binding during the transition state
until the user selects an actual secret. This preserves the key and
value so nothing is lost.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 15:09:54 +00:00
Dotta
ebc6888e7d
Merge pull request #1923 from radiusred/fix/docker-volumes
fix(docker): remap container UID/GID at runtime to avoid volume mount permission errors
2026-03-31 08:46:27 -05:00
Dotta
9f1bb350fe
Merge pull request #2065 from edimuj/fix/heartbeat-session-reuse
fix: preserve session continuity for timer/heartbeat wakes
2026-03-31 08:29:45 -05:00
Dotta
46ce546174
Merge pull request #2317 from paperclipai/PAP-881-document-revisions-bulid-it
Add issue document revision restore flow
2026-03-31 08:25:07 -05:00
dotta
90889c12d8 fix(db): make document revision migration replay-safe 2026-03-31 08:09:00 -05:00
dotta
761dce559d test(worktree): avoid assuming a specific free port 2026-03-31 07:44:19 -05:00
dotta
41f261eaf5 Merge public-gh/master into PAP-881-document-revisions-bulid-it 2026-03-31 07:31:17 -05:00
Dotta
8427043431
Merge pull request #112 from kevmok/add-gpt-5-4-xhigh-effort
Add gpt-5.4 fallback and xhigh effort options
2026-03-31 06:19:38 -05:00
Dotta
19aaa54ae4
Merge branch 'master' into add-gpt-5-4-xhigh-effort 2026-03-31 06:19:26 -05:00
Cody (Radius Red)
d134d5f3a1 fix: support host UID/GID mapping for volume mounts
- Add USER_UID/USER_GID build args to Dockerfile
- Install gosu and remap node user/group at build time
- Set node home directory to /paperclip so agent credentials resolve correctly
- Add docker-entrypoint.sh for runtime UID/GID remapping via gosu

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 23:48:21 +00:00
Dotta
98337f5b03
Merge pull request #2203 from paperclipai/pap-1007-workspace-followups
fix: preserve workspace continuity across follow-up issues
2026-03-30 15:24:47 -05:00
dotta
477ef78fed Address Greptile feedback on workspace reuse
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:55:44 -05:00
Dotta
b0e0f8cd91
Merge pull request #2205 from paperclipai/pap-1007-publishing-docs
docs: add manual @paperclipai/ui publishing prerequisites
2026-03-30 14:48:52 -05:00
Dotta
ccb5cce4ac
Merge pull request #2204 from paperclipai/pap-1007-operator-polish
fix: apply operator polish across comments, invites, routines, and health
2026-03-30 14:48:24 -05:00
Dotta
5575399af1
Merge pull request #2048 from remdev/fix/codex-rpc-client-spawn-error
fix(codex) rpc client spawn error
2026-03-30 14:24:33 -05:00
dotta
2c75c8a1ec docs: clarify npm prerequisites for first ui publish
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:15:30 -05:00
dotta
d8814e938c docs: add manual @paperclipai/ui publish steps
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:15:30 -05:00
dotta
a7cfbc98f3 Fix optimistic comment draft clearing 2026-03-30 14:14:36 -05:00
dotta
5e65bb2b92 Add company name to invite summaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:14:14 -05:00
dotta
d7d01e9819 test: add company settings selectors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:14:14 -05:00
dotta
88e742a129 Fix health DB connectivity probe
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:14:14 -05:00
dotta
db4e146551 Fix routine modal scrolling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:14:14 -05:00
dotta
9684e7bf30 Add dark mode inbox selection color
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:14:14 -05:00
dotta
a3e125f796 Clarify Claude transcript event categories
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:13:52 -05:00
dotta
2b18fc4007 Repair server workspace package links in worktrees
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:10:36 -05:00
dotta
ec1210caaa Preserve workspaces for follow-up issues
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:10:36 -05:00
dotta
3c66683169 Fix execution workspace reuse and slugify worktrees
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 14:10:36 -05:00
Dotta
c610192c53
Merge pull request #2074 from paperclipai/pap-979-runtime-workspaces
feat: expand execution workspace runtime controls
2026-03-30 08:35:50 -05:00
dotta
4d61dbfd34 Merge public-gh/master into pap-979-runtime-workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 08:35:30 -05:00
Dotta
26a974da17
Merge pull request #2072 from paperclipai/pap-979-board-ux
ui: improve board inbox and issue detail workflows
2026-03-30 08:31:29 -05:00
Dotta
8a368e8721
Merge pull request #2176 from paperclipai/fix/revert-paperclipai-script-path-clean
fix: restore root paperclipai script tsx path
2026-03-30 08:31:03 -05:00
dotta
c8ab70f2ce fix: restore paperclipai tsx script path 2026-03-30 08:20:00 -05:00
Dotta
29da357c5b
Merge pull request #2071 from paperclipai/pap-979-cli-onboarding
cli: preserve config when onboarding existing installs
2026-03-30 07:45:19 -05:00
Dotta
4120016d30
Merge pull request #2070 from paperclipai/pap-979-commit-metrics
chore: add Paperclip commit metrics exporter
2026-03-30 07:44:10 -05:00
Dotta
fceefe7f09
Merge pull request #2171 from paperclipai/PAP-987-pr-1001-vite-hmr
fix: preserve PWA tags and StrictMode-safe live updates
2026-03-30 07:38:51 -05:00
Dotta
2d31c71fbe
Merge pull request #1744 from mvanhorn/fix/board-mutation-forwarded-host
fix(server): include x-forwarded-host in board mutation origin check
2026-03-30 07:34:08 -05:00
dotta
b5efd8b435 Merge public-gh/master into fix/hmr-websocket-reverse-proxy
Reconcile the PR with current master, preserve both PWA capability meta tags, and add websocket lifecycle coverage for the StrictMode-safe live updates fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 07:17:23 -05:00
dotta
fc2be204e2 Fix CLI README Discord badge
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 06:49:15 -05:00
dotta
92ebad3d42 Address runtime workspace review feedback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 06:48:45 -05:00
dotta
5310bbd4d8 Address board UX review feedback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 06:46:21 -05:00
dotta
c54b985d9f Handle commit metrics search edge cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 06:44:46 -05:00
Edin Mujkanovic
70702ce74f fix: preserve session continuity for timer/heartbeat wakes
Timer wakes had no taskKey, so they couldn't use agentTaskSessions for
session resume. Adds a synthetic __heartbeat__ task key for timer wakes
so they participate in the full session system.

Includes 6 dedicated unit tests for deriveTaskKeyWithHeartbeatFallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:19:02 +02:00
dotta
b1b3408efa Restrict sidebar reordering to mouse input
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
57357991e4 Set inbox selection to fixed light gray
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
50577b8c63 Neutralize selected inbox accents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
1871a602df Align inbox non-issue selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
facf994694 Align inbox click selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
403aeff7f6 Refine mine inbox shortcut behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
7d81e4cb2a Fix mine inbox keyboard selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
44f052f4c5 Fix inbox selection highlight to show on individual items
Replace outline approach (blended with card border, invisible) with:
- 3px blue left-border bar (absolute positioned, like Gmail)
- Subtle tinted background with forced transparent children so the
  highlight shows through opaque child backgrounds

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
c33dcbd202 Fix keyboard shortcuts using refs to avoid stale closures
Refactored keyboard handler to use refs (kbStateRef, kbActionsRef) for
all mutable state and actions. This ensures the single stable event
listener always reads fresh values instead of relying on effect
dependency re-registration which could miss updates.

Also fixed selection highlight visibility: replaced bg-accent (too
subtle) with bg-primary/10 + outline-primary/30 which is clearly
visible in both light and dark modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
bc61eb84df Remove comment composer interrupt checkbox
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
74687553f3 Improve queued comment thread UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4226e15128 Add issue comment interrupt support
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
cfb7dd4818 Harden optimistic comment IDs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
52bb4ea37a Add optimistic issue comment rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
3986eb615c fix(ui): harden issue breadcrumb source routing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
0f9faa297b Style markdown links with underline and pointer cursor
Links in both rendered markdown (.paperclip-markdown) and the MDXEditor
(.paperclip-mdxeditor-content) now display with underline text-decoration
and cursor:pointer by default. Mention chips are excluded from underline
styling to preserve their pill appearance.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:57:34 -05:00
dotta
d917375e35 Fix invisible keyboard selection highlight in inbox
Replace ring-2 outline (clipped by overflow-hidden container) with
bg-accent background color for the selected item. Visible in both
light and dark modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
ce4536d1fa Add agent Mine inbox API surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4fd62a3d91 fix: prevent 'Mark all as read' from wrapping on mobile
Restructured the inbox header layout to always keep tabs and the
button on the same row using flex justify-between (no responsive
column stacking). Filter dropdowns for the All tab are now on a
separate row below.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
25066c967b fix: clamp mention dropdown position to viewport on mobile
The portal-rendered mention dropdown could appear off-screen on mobile
devices. Clamp top/left to keep it within the viewport and cap width
to 100vw - 16px.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
1534b39ee3 Move 'Mark all as read' button to top-right of inbox header
Moved the button out of the tabs wrapper and into the right-side flex
container so it aligns to the right instead of wrapping below the tabs.
The button now sits alongside the filter dropdowns (on the All tab) or
alone on the right (on other tabs).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
826da2973d Tighten mine-only inbox swipe archive
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4426d96610 Restrict inbox keyboard shortcuts to mine tab only
All keyboard shortcuts (j/k/a/y/U/r/Enter) now only fire when the
user is on the "Mine" tab. Previously j/k and other navigation
shortcuts were active on all tabs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
c8956094ad Add y as inbox archive shortcut alongside a
Both a and y now archive the selected item in the mine tab.
Archive requires selecting an item first with j/k navigation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
2ec4ba629e Add mail-client keyboard shortcuts to inbox mine tab
j/k navigate up/down, a to archive, U to mark unread, r to mark read,
Enter to open. Includes server-side DELETE /issues/:id/read endpoint
for mark-unread support on issues.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
182b459235 Add "Today" divider line in inbox between recent and older items
Shows a dark gray horizontal line with "Today" label on the right,
vertically centered, between items from the last 24 hours and older
items. Applies to all inbox tabs (Mine, Recent, Unread, All).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
94d6ae4049 Fix inbox swipe-to-archive click-through
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
b3d61a7561 Clarify manual workspace runtime behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:45 -05:00
dotta
d9005405b9 Add linked issues row to execution workspace detail
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
e3f07aad55 Fix execution workspace runtime control reuse
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
2fea39b814 Reduce run lifecycle toast noise
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
0356040a29 Improve workspace detail mobile layouts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
caa7550e9f Fix shared workspace close semantics
Allow shared execution workspace sessions to be archived with warnings instead of hard-blocking on open linked issues, clear issue workspace links when those shared sessions are archived, and update the close dialog copy and coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
84d4c328f5 Harden runtime service env sanitization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
11f08ea5d5 Fix execution workspace close messaging
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
1f1fe9c989 Add workspace runtime controls
Expose project and execution workspace runtime defaults, control endpoints, startup recovery, and operator UI for start/stop/restart flows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
f1ad07616c Add execution workspace close readiness and UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
868cfa8c50 Auto-apply dev:once migrations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
6793dde597 Add idempotent local dev service management
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
cadfcd1bc6 Log resolved adapter command in run metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00
dotta
c114ff4dc6 Improve execution workspace detail editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
84e35b801c Fix execution workspace company routing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
cbeefbfa5a Fix project workspace detail route loading
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
2de691f023 Link workspace titles from project tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
41f2a80aa8 Fix issue workspace detail links
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
bb1732dd11 Add project workspace detail page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
15e0e2ece9 Add workspace path copy control
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
b7b5d8dae3 Polish workspace issue badges
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
0ff778ec29 Exclude default shared workspaces from tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
b69f0b7dc4 Adjust workspace row columns
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
b75ac76b13 Add project workspaces tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:21 -05:00
dotta
19b6adc415 Use exported tsx CLI entrypoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:51:58 -05:00
dotta
54b05d6d68 Make onboarding reruns preserve existing config
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:51:58 -05:00
dotta
f83a77f41f Add cli/README.md with absolute image URLs for npm
The root README uses relative doc/assets/ paths which work on GitHub
but break on npmjs.com since those files aren't in the published
tarball. This adds a cli-specific README with absolute
raw.githubusercontent.com URLs so images render on npm.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:51:58 -05:00
dotta
a3537a86e3 Add filtered Paperclip commit exports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:51:16 -05:00
dotta
5d538d4792 Add Paperclip commit metrics script
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:51:16 -05:00
Mikhail Batukhtin
dc3aa8f31f test(codex-local): isolate quota spawn test from host CODEX_HOME
After the mocked RPC spawn fails, getQuotaWindows() still calls
readCodexToken(). Use an empty mkdtemp directory for CODEX_HOME for the
duration of the test so we never read ~/.codex/auth.json or call WHAM.
2026-03-29 15:15:37 +03:00
Mikhail Batukhtin
c98af52590 test(codex-local): regression for CodexRpcClient spawn ENOENT
Add a Vitest case that mocks `node:child_process.spawn` so the child
emits `error` (ENOENT) after the constructor attaches listeners.
`getQuotaWindows()` must resolve with `ok: false` instead of leaving an
unhandled `error` event on the process.

Register `packages/adapters/codex-local` in the root Vitest workspace.

Document in DEVELOPING.md that a missing `codex` binary should not take
down the API server during quota polling.
2026-03-29 14:43:51 +03:00
Mikhail Batukhtin
01fb97e8da fix(codex-local): handle spawn error event in CodexRpcClient
When the `codex` binary is absent from PATH, Node.js emits an `error`
event on the ChildProcess. Because `CodexRpcClient` only subscribed to
`exit` and `data` events, the `error` event was unhandled — causing
Node to throw it as an uncaught exception and crash the server.

Add an `error` handler in the constructor that rejects all pending RPC
requests and clears the queue. This makes a missing `codex` binary a
recoverable condition: `fetchCodexRpcQuota()` rejects, `getQuotaWindows()`
catches the error and returns `{ ok: false }`, and the server stays up.

The fix mirrors the existing pattern in `runChildProcess`
(packages/adapter-utils/src/server-utils.ts) which already handles
`ENOENT` the same way for the main task execution path.
2026-03-29 14:20:55 +03:00
Dotta
6a72faf83b
Merge pull request #1949 from vanductai/fix/dev-watch-tsx-cli-path
Some checks failed
Docker / build-and-push (push) Has been cancelled
Refresh Lockfile / refresh (push) Has been cancelled
Release / verify_canary (push) Has been cancelled
Release / verify_stable (push) Has been cancelled
Release / publish_canary (push) Has been cancelled
Release / preview_stable (push) Has been cancelled
Release / publish_stable (push) Has been cancelled
fix(server): use stable tsx/cli entry point in dev-watch
2026-03-28 16:45:04 -05:00
Dotta
1fd40920db
Merge pull request #1974 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-28 06:50:53 -05:00
lockfile-bot
caef115b95 chore(lockfile): refresh pnpm-lock.yaml 2026-03-28 11:46:21 +00:00
Dotta
17e5322e28
Merge pull request #1955 from HenkDz/feat/hermes-adapter-upgrade
feat(hermes): upgrade hermes-paperclip-adapter + UI adapter, skills, model detection
2026-03-28 06:46:01 -05:00
HenkDz
582f4ceaf4 fix: address Hermes adapter review feedback 2026-03-28 11:35:58 +01:00
HenkDz
1583a2d65a feat(hermes): upgrade hermes-paperclip-adapter + UI adapter + skills + detectModel
Upgrades hermes-paperclip-adapter from 0.1.1 to ^0.2.0 and wires in all new
capabilities introduced in v0.2.0:

Server
- Upgrade hermes-paperclip-adapter 0.1.1 -> ^0.2.0 (pending PR#10 merge)
- Wire listSkills + syncSkills from hermes-paperclip-adapter/server
- Add detectModel to hermesLocalAdapter (reads ~/.hermes/config.yaml)
- Add detectAdapterModel() function + /adapters/:type/detect-model route
- Export detectAdapterModel from server/src/adapters/index.ts

Types
- Add optional detectModel? to ServerAdapterModule in adapter-utils

UI
- Add hermes-paperclip-adapter ^0.2.0 to ui/package.json (for /ui exports)
- New ui/src/adapters/hermes-local/ — config fields + UI adapter module
- Register hermesLocalUIAdapter in UI adapter registry
- New HermesIcon (caduceus SVG) for adapter pickers
- AgentConfigForm: detect-model button, creatable model input, preserve
  adapter-agnostic fields (env, promptTemplate) when switching adapter type
- NewAgentDialog + OnboardingWizard: add Hermes to adapter picker
- Agents, OrgChart, InviteLanding, NewAgent, agent-config-primitives: add
  hermes_local label + enable in adapter sets
- AgentDetail: smarter run summary excerpt extraction
- RunTranscriptView: improved Hermes stdout rendering

NOTE: requires hermes-paperclip-adapter@0.2.0 on npm.
      Blocked on NousResearch/hermes-paperclip-adapter#10 merging.
2026-03-28 01:34:48 +01:00
vanductai
9a70a4edaa fix(server): use stable tsx/cli entry point in dev-watch
The dev-watch script was importing tsx via the internal path
'tsx/dist/cli.mjs', which is an undocumented implementation detail
that broke when tsx updated its internal structure.

Switched to the stable public export 'tsx/cli' which is the
officially supported entry point and won't break across versions.
2026-03-28 06:42:03 +07:00
Dotta
0ac01a04e5
Merge pull request #1891 from paperclipai/docs/maintenance-20260327-public
docs: documentation accuracy update 2026-03-27
2026-03-27 07:47:24 -05:00
dotta
11ff24cd22 docs: fix adapter type references and complete adapter table
- Fix openclaw → openclaw_gateway type key in adapters overview and managing-agents guide
- Add missing adapters to overview table: hermes_local, cursor, pi_local
- Mark gemini_local as experimental (adapter package exists but not in stable type enum)
- Update "Choosing an Adapter" recommendations to match stable adapter set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:05:08 -05:00
Devin Foley
a5d47166e2
docs: add board-operator delegation guide (#1889)
* docs: add board-operator delegation guide

Create docs/guides/board-operator/delegation.md explaining the full
CEO-led delegation lifecycle from the board operator's perspective.
Covers what the board needs to do, what the CEO automates, common
delegation patterns (flat, 3-level, hire-on-demand), and a
troubleshooting section that directly answers the #1 new-user
confusion point: "Do I have to tell the CEO to delegate?"

Also adds a Delegation section to core-concepts.md and wires the
new guide into docs.json navigation after Managing Tasks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: add AGENTS.md troubleshooting note to delegation guide

Add a row to the troubleshooting table telling board operators to
verify the CEO's AGENTS.md instructions file contains delegation
directives. Without these instructions, the CEO won't delegate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: fix stale concept count and frontmatter summary

Update "five key concepts" to "six" and add "delegation" to the
frontmatter summary field, addressing Greptile review comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-03-26 23:01:58 -07:00
Matt Van Horn
eb8c5d93e7
test(server): add negative test for x-forwarded-host mismatch
Verifies the board mutation guard blocks requests when
X-Forwarded-Host is present but Origin does not match it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:39:46 -07:00
Dotta
af5b980362
Merge pull request #1857 from paperclipai/PAP-878-create-a-mine-tab-in-inbox
Add a Mine tab and archive flow to inbox
2026-03-26 16:21:47 -05:00
dotta
b0b9809732 Add issue document revision restore flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 08:24:57 -05:00
Matt Van Horn
d0e01d2863
fix(server): include x-forwarded-host in board mutation origin check
Behind a reverse proxy with a custom port (e.g. Caddy on :3443), the
browser sends an Origin header that includes the port, but the board
mutation guard only read the Host header which often omits the port.
This caused a 403 "Board mutation requires trusted browser origin"
for self-hosted deployments behind reverse proxies.

Read x-forwarded-host (first value, comma-split) with the same pattern
already used in private-hostname-guard.ts and routes/access.ts.

Fixes #1734
2026-03-25 00:06:43 -07:00
Genie
59b1d1551a fix: Vite HMR WebSocket for reverse proxy + WS StrictMode guard
When running behind a reverse proxy (e.g. Caddy), the live-events
WebSocket would fail to connect because it constructed the URL from
window.location without accounting for proxy routing.

Also fixes React StrictMode double-invoke of WebSocket connections
by deferring the connect call via a cleanup guard.

- Replace deprecated apple-mobile-web-app-capable meta tag
- Guard WS connect with mounted flag to prevent StrictMode double-open
- Use protocol-relative WebSocket URL derivation for proxy compatibility
2026-03-17 07:09:00 -03:00
Kevin Mok
432d7e72fa Merge upstream/master into add-gpt-5-4-xhigh-effort 2026-03-08 12:10:59 -05:00
Kevin Mok
666ab53648 Remove redundant opencode model assertion 2026-03-05 19:55:15 -06:00
Kevin Mok
314288ff82 Add gpt-5.4 fallback and xhigh effort options 2026-03-05 18:59:42 -06:00
288 changed files with 33851 additions and 1758 deletions

View file

@ -0,0 +1,83 @@
# Nexus Rebase Runbook
Step-by-step workflow for rebasing Nexus fork commits onto new upstream Paperclip releases.
## Prerequisites
- `git rerere` enabled: `git config rerere.enabled true`
- `git range-diff` available (git 2.19+, confirmed 2.39.5 on this machine)
- Upstream remote configured: `git remote add upstream https://github.com/paperclipai/paperclip.git` (if not already)
## Pre-Rebase Checklist
1. Ensure working tree is clean: `git status`
2. Fetch upstream: `git fetch upstream`
3. Record current tip: `git log --oneline -1` (save this SHA as OLD_TIP)
4. Verify all tests pass before rebase: `pnpm test:run`
## Rebase Procedure
```bash
# 1. Fetch latest upstream
git fetch upstream
# 2. Rebase nexus commits onto upstream/master
git rebase upstream/master
# 3. If conflicts arise:
# - git rerere will auto-apply previously recorded resolutions
# - For new conflicts: resolve manually, then `git add` + `git rebase --continue`
# - rerere automatically records new resolutions for future use
# 4. Verify rebase integrity with range-diff
# ORIG_HEAD is the pre-rebase tip (set automatically by git)
git range-diff upstream/master ORIG_HEAD HEAD
```
## Post-Rebase Verification
1. **range-diff check:** `git range-diff upstream/master ORIG_HEAD HEAD`
- Every nexus commit should show as "equivalent" (minor offset changes only)
- Flag any commit showing significant diff changes for manual review
2. **Test suite:** `pnpm test:run` — all tests must pass
3. **Type check:** `pnpm typecheck` (if available) or `pnpm -r run typecheck`
4. **Branding spot check:** `pnpm vitest run --project packages/branding`
## Handling Common Scenarios
### Upstream changed a file we also changed (DISPLAY zone)
- Most common: string changes in UI components
- rerere should handle if previously resolved
- If new: resolve keeping Nexus display string, `git add`, continue
### Upstream added new constants to packages/shared/src/constants.ts
- Our changes are in `packages/branding/` (separate file) — no conflict expected
- If AGENT_ROLE_LABELS format changes upstream, update the DISPLAY zone mapping
### Upstream restructured a file entirely
- range-diff will show the affected nexus commit as "changed"
- Manually verify the nexus change still applies correctly
- Update zone taxonomy if file paths changed
## rerere Cache Notes
- Cache lives in `.git/rr-cache/` (not tracked by git)
- Cache is machine-local — lost on re-clone
- After a fresh clone, first rebase may require manual resolution
- Subsequent rebases at the same conflict points will auto-resolve
## Hook Re-installation
After a fresh clone, the commit-msg hook must be reinstalled:
```bash
# From repo root:
cp scripts/nexus-commit-msg-hook.sh .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
```
Or using the install script:
```bash
bash scripts/install-hooks.sh
```

View file

@ -0,0 +1,77 @@
# Nexus Zone Taxonomy
Classifies every Paperclip-to-Nexus rename target by zone.
Zones determine which occurrences are safe to change and which must stay unchanged for upstream sync.
**Zones:**
- **DISPLAY** — User-facing strings safe to rename (UI text, banners, tooltips, help text, button labels)
- **CODE** — TypeScript identifiers, import paths, route segments, env vars — do NOT touch
- **STORED** — DB column/table names, stored enum values — do NOT touch
---
## DISPLAY Zone (safe to change in Phases 2-4)
| Target | Location | Current Value | Nexus Value | Phase |
|--------|----------|---------------|-------------|-------|
| Company display string in JSX | ~16 UI files in `ui/src/` | "Company" | "Workspace" | 3 |
| Companies plural in JSX | UI files | "Companies" | "Workspaces" | 3 |
| CEO display string in JSX | `ui/src/components/agent-config-primitives.tsx`, `AgentProperties.tsx`, etc. | "CEO" | "Project Manager" | 3 |
| Board display string in JSX | Various UI files | "Board" | "Owner" | 3 |
| Hire button text | UI dialogs | "Hire" | "Add" | 3 |
| Fire button text | UI dialogs | "Fire" | "Remove" | 3 |
| `AGENT_ROLE_LABELS.ceo` value | `packages/shared/src/constants.ts` | `"CEO"` | `"Project Manager"` | 2 |
| PAPERCLIP ASCII banner | `server/src/startup-banner.ts` | "PAPERCLIP" | "NEXUS" | 2 |
| PAPERCLIP ASCII banner (CLI) | `cli/src/utils/banner.ts` | "PAPERCLIP" | "NEXUS" | 2 |
| App title in browser tab | `ui/index.html` or layout | "Paperclip" | "Nexus" | 3 |
| Top-left logo text | UI layout component | "Paperclip" | "Nexus" | 3 |
| CLI help text brand name | `cli/src/` command descriptions | "Paperclip" | "Nexus" | 3 |
| paperclip.ing URL references | `ui/src/pages/CompanyExport.tsx` | "paperclip.ing" | Nexus URL | 3 |
| Favicon and logo assets | `ui/public/` or assets dir | Paperclip branding | Nexus branding | 3 |
---
## CODE Zone (do NOT touch — upstream sync priority)
| Target | Location | Rationale |
|--------|----------|-----------|
| `companyService`, `companyId`, `selectedCompanyId` | Throughout server/ui/cli | TypeScript identifiers — hundreds of import references |
| `companies` table name | `packages/db/src/schema/` | DB table — migration required to rename |
| `company_id` FK columns | `packages/db/src/schema/` | DB columns — migration required |
| `/api/companies` route segment | `server/src/routes/companies.ts` | API contract — client/server must match |
| `COMPANY_STATUSES` / `CompanyStatus` type | `packages/shared/src/constants.ts` | Upstream shared type — plugin API contract |
| `@paperclipai/*` package names | All `package.json` files | Import paths throughout monorepo |
| `PAPERCLIP_*` env vars | Server/CLI config | Breaks existing deployments |
| `board_api_keys` table / `board` actor type | DB schema, auth code | Auth token format, DB schema |
| `pcp_board_*` token prefixes | Auth code | Would invalidate issued tokens |
| `.paperclip.yaml` export format | Import/export code | Upstream compatibility |
---
## STORED Zone (do NOT touch — DB integrity)
| Target | Location | Stored Where | Rationale |
|--------|----------|-------------|-----------|
| `"ceo"` in `AGENT_ROLES` | `packages/shared/src/constants.ts` | `agent_role` DB column | Existing rows contain this value |
| `"hire_agent"` approval type | `packages/shared/src/constants.ts` APPROVAL_TYPES | `approval_type` DB column | Existing approvals reference this |
| `"approve_ceo_strategy"` | `packages/shared/src/constants.ts` APPROVAL_TYPES | `approval_type` DB column | Existing approvals reference this |
| `"bootstrap_ceo"` invite type | `packages/shared/src/constants.ts` | `invite_type` DB column | Existing invites reference this |
| `company_id` FK values | All FK columns | PostgreSQL foreign keys | Data integrity constraint |
---
## Zone Summary
| Zone | Count | Rule |
|------|-------|------|
| DISPLAY | ~40 surface points | Safe to rename in Phases 2-4 |
| CODE | Many hundreds | Never rename — upstream sync priority |
| STORED | ~8 enum/column values | Never rename — DB integrity |
---
## Decision Rule
When the same term appears in multiple zones (e.g., "ceo" is both STORED as `AGENT_ROLES[0]` and DISPLAY as `AGENT_ROLE_LABELS.ceo` value), classify each occurrence independently. The key stays, only the display value changes.
**Example:** `AGENT_ROLES` contains `"ceo"` (STORED — do not touch). `AGENT_ROLE_LABELS.ceo` has value `"CEO"` (DISPLAY — safe to change to `"Project Manager"`). Both live in the same file (`packages/shared/src/constants.ts`), but the treatment differs per occurrence.

View file

@ -1,9 +1,17 @@
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& apt-get install -y --no-install-recommends ca-certificates curl git gosu \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
@ -35,12 +43,17 @@ RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
@ -48,6 +61,8 @@ ENV NODE_ENV=production \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
@ -55,5 +70,5 @@ ENV NODE_ENV=production \
VOLUME ["/paperclip"]
EXPOSE 3100
USER node
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]

View file

@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash

292
cli/README.md Normal file
View file

@ -0,0 +1,292 @@
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
</p>
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<br/>
<div align="center">
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
</div>
<br/>
## What is Paperclip?
# Open-source orchestration for zero-human companies
**If OpenClaw is an _employee_, Paperclip is the _company_**
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard.
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.**
| | Step | Example |
| ------ | --------------- | ------------------------------------------------------------------ |
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
<br/>
> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds.
<br/>
<div align="center">
<table>
<tr>
<td align="center"><strong>Works<br/>with</strong></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
</tr>
</table>
<em>If it can receive a heartbeat, it's hired.</em>
</div>
<br/>
## Paperclip is right for you if
- ✅ You want to build **autonomous AI companies**
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
- ✅ You want to **monitor costs** and enforce budgets
- ✅ You want a process for managing agents that **feels like using a task manager**
- ✅ You want to manage your autonomous businesses **from your phone**
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🔌 Bring Your Own Agent</h3>
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
</td>
<td align="center" width="33%">
<h3>🎯 Goal Alignment</h3>
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
</td>
<td align="center" width="33%">
<h3>💓 Heartbeats</h3>
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
</td>
</tr>
<tr>
<td align="center">
<h3>💰 Cost Control</h3>
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
</td>
<td align="center">
<h3>🏢 Multi-Company</h3>
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
</td>
<td align="center">
<h3>🎫 Ticket System</h3>
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
</td>
</tr>
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
</td>
<td align="center">
<h3>📱 Mobile Ready</h3>
Monitor and manage your autonomous businesses from anywhere.
</td>
</tr>
</table>
<br/>
## Problems Paperclip solves
| Without Paperclip | With Paperclip |
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
<br/>
## Why Paperclip is special
Paperclip handles the hard orchestration details correctly.
| | |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
<br/>
## What Paperclip is not
| | |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Not a chatbot.** | Agents have jobs, not chat windows. |
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
<br/>
## Quickstart
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash
git clone https://github.com/paperclipai/paperclip.git
cd paperclip
pnpm install
pnpm dev
```
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
<br/>
## FAQ
**What does a typical setup look like?**
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
**Can I run multiple companies?**
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
**How is Paperclip different from agents like OpenClaw or Claude Code?**
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
(Bring-your-own-ticket-system is on the Roadmap)
**Do agents run continuously?**
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
<br/>
## Development
```bash
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test:run # Run tests
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
<br/>
## Roadmap
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
- ✅ Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- ✅ Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ⚪ Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- ⚪ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App
<br/>
## Community & Plugins
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
## Contributing
We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details.
<br/>
## Community
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
<br/>
## License
MIT &copy; 2026 Paperclip
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<br/>
---
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/footer.jpg" alt="" width="720" />
</p>
<p align="center">
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
</p>

View file

@ -45,6 +45,7 @@
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/branding": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",
"@paperclipai/shared": "workspace:*",

View file

@ -278,7 +278,7 @@ describe("renderCompanyImportPreview", () => {
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("workspace, projects, tasks, agents, skills"); // [nexus] updated from "company" to "workspace"
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
@ -319,7 +319,7 @@ describe("renderCompanyImportResult", () => {
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("Workspace"); // [nexus] updated from "Company" to "Workspace"
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");

View file

@ -72,7 +72,7 @@ describe("PaperclipApiClient", () => {
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
/Could not reach the Nexus API\./, // [nexus] updated from "Paperclip API" to "Nexus API"
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,

View file

@ -0,0 +1,105 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { onboard } from "../commands/onboard.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
const runtimeRoot = path.join(root, "runtime");
const configPath = path.join(root, ".paperclip", "config.json");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-29T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
}
describe("onboard", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("preserves an existing config when rerun without flags", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("preserves an existing config when rerun with --yes", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath, yes: true, invokedByRun: true });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
});

View file

@ -415,7 +415,7 @@ describe("worktree helpers", () => {
});
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
expect(config.server.port).toBe(3102);
expect(config.server.port).toBeGreaterThan(3101);
expect(config.database.embeddedPostgresPort).not.toBe(54330);
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);

View file

@ -4,6 +4,7 @@ import {
readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js";
export function agentJwtSecretCheck(configPath?: string): CheckResult {
@ -23,7 +24,7 @@ export function agentJwtSecretCheck(configPath?: string): CheckResult {
name: "Agent JWT secret",
status: "warn",
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
repairHint: `Set the value from ${envPath} in your shell before starting the ${VOCAB.appName} server`,
};
}

View file

@ -1,4 +1,5 @@
import type { PaperclipConfig } from "../config/schema.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) {
@ -37,7 +38,7 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
status: "fail",
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
canRepair: false,
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
repairHint: `Set BETTER_AUTH_SECRET before starting ${VOCAB.appName}`,
};
}

View file

@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
import { buildCliCommandLabel } from "./command-label.js";
import { resolveDefaultCliAuthPath } from "../config/home.js";
@ -215,7 +216,7 @@ export async function loginBoardCli(params: {
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
if (params.print !== false) {
console.error(pc.bold("Board authentication required"));
console.error(pc.bold(`${VOCAB.board} authentication required`)); // [nexus]
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
}

View file

@ -1,4 +1,5 @@
import { URL } from "node:url";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
export class ApiRequestError extends Error {
status: number;
@ -205,7 +206,7 @@ function buildConnectionErrorMessage(input: {
}): string {
const healthUrl = buildHealthCheckUrl(input.url);
const lines = [
"Could not reach the Paperclip API.",
`Could not reach the ${VOCAB.appName} API.`, // [nexus]
"",
`Request: ${input.method} ${input.url}`,
];
@ -214,12 +215,12 @@ function buildConnectionErrorMessage(input: {
}
lines.push(
"",
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
`This usually means the ${VOCAB.appName} server is not running, the configured URL is wrong, or the request is being blocked before it reaches ${VOCAB.appName}.`, // [nexus]
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
`- Start ${VOCAB.appName} with \`pnpm dev\` or \`pnpm paperclipai run\`.`, // [nexus]
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
`- If ${VOCAB.appName} is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`, // [nexus]
);
return lines.join("\n");
}

View file

@ -1,4 +1,5 @@
import * as p from "@clack/prompts";
import { VOCAB } from "@paperclipai/branding";
import pc from "picocolors";
import { normalizeHostnameInput } from "../config/hostnames.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
@ -27,7 +28,7 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
pc.dim(`Restart the ${VOCAB.appName} server for this change to take effect.`),
);
}

View file

@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
@ -57,12 +58,12 @@ export async function bootstrapCeoInvite(opts: {
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("nexus onboard")} first.`); // [nexus]
return;
}
if (config.server.deploymentMode !== "authenticated") {
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
p.log.info(`Deployment mode is local_trusted. Bootstrap ${VOCAB.ceo} invite is only required for authenticated mode.`); // [nexus]
return;
}
@ -121,12 +122,12 @@ export async function bootstrapCeoInvite(opts: {
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
const inviteUrl = `${baseUrl}/invite/${token}`;
p.log.success("Created bootstrap CEO invite.");
p.log.success(`Created bootstrap ${VOCAB.ceo} invite.`); // [nexus]
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
p.log.info(`If using embedded-postgres, start the ${VOCAB.appName} server and run this command again.`); // [nexus]
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { ActivityEvent } from "@paperclipai/shared";
import {
addCommonClientOptions,
@ -22,8 +23,8 @@ export function registerActivityCommands(program: Command): void {
addCommonClientOptions(
activity
.command("list")
.description("List company activity log entries")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List ${VOCAB.company.toLowerCase()} activity log entries`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <id>", "Filter by entity ID")

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { Agent } from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
@ -162,8 +163,8 @@ export function registerAgentCommands(program: Command): void {
addCommonClientOptions(
agent
.command("list")
.description("List agents for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List agents for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.action(async (opts: AgentListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
@ -222,7 +223,7 @@ export function registerAgentCommands(program: Command): void {
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import {
createApprovalSchema,
requestApprovalRevisionSchema,
@ -48,8 +49,8 @@ export function registerApprovalCommands(program: Command): void {
addCommonClientOptions(
approval
.command("list")
.description("List approvals for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List approvals for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--status <status>", "Status filter")
.action(async (opts: ApprovalListOptions) => {
try {
@ -110,7 +111,7 @@ export function registerApprovalCommands(program: Command): void {
approval
.command("create")
.description("Create an approval request")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID")

View file

@ -11,6 +11,7 @@ import type {
CompanyPortabilityPreviewResult,
CompanyPortabilityImportResult,
} from "@paperclipai/shared";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
import { ApiRequestError } from "../../client/http.js";
import { openUrl } from "../../client/board-auth.js";
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
@ -78,11 +79,11 @@ const IMPORT_INCLUDE_OPTIONS: Array<{
label: string;
hint: string;
}> = [
{ value: "company", label: "Company", hint: "name, branding, and company settings" },
{ value: "company", label: VOCAB.company, hint: "name, branding, and workspace settings" }, // [nexus]
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
{ value: "skills", label: "Skills", hint: "company skill packages and references" },
{ value: "skills", label: "Skills", hint: `${VOCAB.company.toLowerCase()} skill packages and references` }, // [nexus]
];
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
@ -389,8 +390,8 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult
options: [
{
value: "company",
label: state.company ? "Company: included" : "Company: skipped",
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
label: state.company ? `${VOCAB.company}: included` : `${VOCAB.company}: skipped`, // [nexus]
hint: catalog.company.files.length > 0 ? `toggle ${VOCAB.company.toLowerCase()} metadata` : `no ${VOCAB.company.toLowerCase()} metadata in package`, // [nexus]
},
{
value: "projects",
@ -662,7 +663,7 @@ export function renderCompanyImportResult(
): string {
const lines: string[] = [
`${pc.bold("Target")} ${meta.targetLabel}`,
`${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`,
`${pc.bold(VOCAB.company)} ${result.company.name} (${actionChip(result.company.action)})`, // [nexus]
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
];
@ -1040,12 +1041,12 @@ function assertDeleteFlags(opts: CompanyDeleteOptions): void {
}
export function registerCompanyCommands(program: Command): void {
const company = program.command("company").description("Company operations");
const company = program.command("company").description(`${VOCAB.company} operations`) // [nexus];
addCommonClientOptions(
company
.command("list")
.description("List companies")
.description(`List ${VOCAB.companies.toLowerCase()}`) // [nexus]
.action(async (opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
@ -1080,8 +1081,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("get")
.description("Get one company")
.argument("<companyId>", "Company ID")
.description(`Get one ${VOCAB.company.toLowerCase()}`) // [nexus]
.argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.action(async (companyId: string, opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
@ -1096,8 +1097,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("export")
.description("Export a company into a portable markdown package")
.argument("<companyId>", "Company ID")
.description(`Export a ${VOCAB.company.toLowerCase()} into a portable markdown package`) // [nexus]
.argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
@ -1372,8 +1373,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("delete")
.description("Delete a company by ID or shortname/prefix (destructive)")
.argument("<selector>", "Company ID or issue prefix (for example PAP)")
.description(`Delete a ${VOCAB.company.toLowerCase()} by ID or shortname/prefix (destructive)`) // [nexus]
.argument("<selector>", `${VOCAB.company} ID or issue prefix (for example PAP)`) // [nexus]
.option(
"--by <mode>",
"Selector mode: auto | id | prefix",
@ -1382,7 +1383,7 @@ export function registerCompanyCommands(program: Command): void {
.option("--yes", "Required safety flag to confirm destructive action", false)
.option(
"--confirm <value>",
"Required safety value: target company ID or shortname/prefix",
`Required safety value: target ${VOCAB.company.toLowerCase()} ID or shortname/prefix`, // [nexus]
)
.action(async (selector: string, opts: CompanyDeleteOptions) => {
try {
@ -1424,7 +1425,7 @@ export function registerCompanyCommands(program: Command): void {
} catch (error) {
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
throw new Error(
"Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.",
`${VOCAB.board} access is required to resolve ${VOCAB.companies.toLowerCase()} across the instance. Use a ${VOCAB.company.toLowerCase()} ID/prefix for your current ${VOCAB.company.toLowerCase()}, or run with ${VOCAB.board.toLowerCase()} authentication.`, // [nexus]
);
}
throw error;

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { DashboardSummary } from "@paperclipai/shared";
import {
addCommonClientOptions,
@ -18,8 +19,8 @@ export function registerDashboardCommands(program: Command): void {
addCommonClientOptions(
dashboard
.command("get")
.description("Get dashboard summary for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`Get dashboard summary for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.action(async (opts: DashboardGetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import {
addIssueCommentSchema,
checkoutIssueSchema,
@ -67,8 +68,8 @@ export function registerIssueCommands(program: Command): void {
addCommonClientOptions(
issue
.command("list")
.description("List issues for a company")
.option("-C, --company-id <id>", "Company ID")
.description(`List issues for a ${VOCAB.company.toLowerCase()}`)
.option("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--status <csv>", "Comma-separated statuses")
.option("--assignee-agent-id <id>", "Filter by assignee agent ID")
.option("--project-id <id>", "Filter by project ID")
@ -136,7 +137,7 @@ export function registerIssueCommands(program: Command): void {
issue
.command("create")
.description("Create an issue")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.requiredOption("--title <title>", "Issue title")
.option("--description <text>", "Issue description")
.option("--status <status>", "Issue status")

View file

@ -15,7 +15,7 @@ import {
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printNexusCliBanner } from "../utils/banner.js";
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
@ -72,7 +72,7 @@ export async function configure(opts: {
config?: string;
section?: string;
}): Promise<void> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
const configPath = resolveConfigPath(opts.config);

View file

@ -8,7 +8,7 @@ import {
resolvePaperclipInstanceId,
} from "../config/home.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printNexusCliBanner } from "../utils/banner.js";
type DbBackupOptions = {
config?: string;
@ -47,7 +47,7 @@ function resolveBackupDir(raw: string): string {
}
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
const configPath = resolveConfigPath(opts.config);

View file

@ -15,7 +15,7 @@ import {
type CheckResult,
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printNexusCliBanner } from "../utils/banner.js";
const STATUS_ICON = {
pass: pc.green("✓"),
@ -28,7 +28,7 @@ export async function doctor(opts: {
repair?: boolean;
yes?: boolean;
}): Promise<{ passed: number; warned: number; failed: number }> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
const configPath = resolveConfigPath(opts.config);

View file

@ -32,7 +32,91 @@ import {
resolvePaperclipInstanceId,
} from "../config/home.js";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printNexusCliBanner } from "../utils/banner.js";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
// [nexus] Auto-create PM and Engineer agents on first run
async function bootstrapNexusAgents(serverUrl: string, rootDir: string): Promise<void> {
// [nexus] Health-check poll — wait for server to be ready (max 30 seconds)
const maxRetries = 30;
let serverReady = false;
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(`${serverUrl}/api/health`);
if (res.ok) {
serverReady = true;
break;
}
} catch {
// [nexus] Server not ready yet
}
if (i < maxRetries - 1) {
await new Promise<void>((r) => setTimeout(r, 1000));
}
}
if (!serverReady) {
console.warn("[nexus] Server did not become ready in 30s, skipping agent bootstrap");
return;
}
try {
// [nexus] Check if workspace already exists (idempotent — skip if already bootstrapped)
const companiesRes = await fetch(`${serverUrl}/api/companies`);
if (!companiesRes.ok) {
console.warn("[nexus] Could not fetch workspaces, skipping agent bootstrap");
return;
}
const companies = (await companiesRes.json()) as unknown[];
if (companies.length > 0) {
return; // [nexus] Already bootstrapped — skip
}
// [nexus] Create workspace
p.log.step(`Creating your ${VOCAB.company} workspace...`);
const companyRes = await fetch(`${serverUrl}/api/companies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: VOCAB.appName }),
});
if (!companyRes.ok) {
console.warn("[nexus] Could not create workspace, skipping agent bootstrap");
return;
}
const company = (await companyRes.json()) as { id: string };
// [nexus] Create PM agent (role: "ceo" for elevated permissions — displays as Project Manager)
p.log.step(`Adding ${VOCAB.ceo} agent...`);
await fetch(`${serverUrl}/api/companies/${company.id}/agents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Project Manager",
role: "ceo",
adapterType: "claude_local",
adapterConfig: { cwd: rootDir },
}),
});
// [nexus] Create Engineer agent
p.log.step("Adding Engineer agent...");
await fetch(`${serverUrl}/api/companies/${company.id}/agents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: { cwd: rootDir },
}),
});
p.log.success("Workspace and agents created — you're ready to go!");
} catch (err) {
// [nexus] Bootstrap failures are warnings, not errors — user can create agents manually
console.warn("[nexus] Agent bootstrap failed:", err instanceof Error ? err.message : String(err));
}
}
type SetupMode = "quickstart" | "advanced";
@ -234,8 +318,8 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
}
export async function onboard(opts: OnboardOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" nexus onboard "))); // [nexus]
const configPath = resolveConfigPath(opts.config);
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
p.log.message(
@ -244,11 +328,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
),
);
let existingConfig: PaperclipConfig | null = null;
if (configExists(opts.config)) {
p.log.message(pc.dim(`${configPath} exists, updating config`));
p.log.message(pc.dim(`${configPath} exists`));
try {
readConfig(opts.config);
existingConfig = readConfig(opts.config);
} catch (err) {
p.log.message(
pc.yellow(
@ -258,6 +343,76 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
}
if (existingConfig) {
p.log.message(
pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."),
);
p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`));
const jwtSecret = ensureAgentJwtSecret(configPath);
const envFilePath = resolveAgentJwtEnvFile(configPath);
if (jwtSecret.created) {
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
} else {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
}
const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath);
if (keyResult.status === "created") {
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
} else if (keyResult.status === "existing") {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
}
p.note(
[
"Existing config preserved",
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
`Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`,
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
].join("\n"),
"Configuration ready",
);
p.note(
[
`Run: ${pc.cyan("paperclipai run")}`,
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
].join("\n"),
"Next commands",
);
let shouldRunNow = opts.run === true || opts.yes === true;
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
initialValue: true,
});
if (!p.isCancel(answer)) {
shouldRunNow = answer;
}
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
}
p.outro("Existing Paperclip setup is ready.");
return;
}
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
@ -309,7 +464,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
await db.execute("SELECT 1");
s.stop("Database connection successful");
} catch {
s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclipai doctor`"));
s.stop(pc.yellow("Could not connect to database — you can fix this later with `nexus doctor`")); // [nexus]
}
}
@ -447,22 +602,22 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
p.note(
[
`Run: ${pc.cyan("paperclipai run")}`,
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
`Run: ${pc.cyan("nexus run")}`, // [nexus]
`Reconfigure later: ${pc.cyan("nexus configure")}`, // [nexus]
`Diagnose setup: ${pc.cyan("nexus doctor")}`, // [nexus]
].join("\n"),
"Next commands",
);
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
p.log.step(`Generating bootstrap ${VOCAB.ceo} invite`); // [nexus]
await bootstrapCeoInvite({ config: configPath });
}
let shouldRunNow = opts.run === true || opts.yes === true;
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
message: `Start ${VOCAB.appName} now?`, // [nexus]
initialValue: true,
});
if (!p.isCancel(answer)) {
@ -473,6 +628,24 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
const { runCommand } = await import("./run.js");
// [nexus] Start bootstrap concurrently — health-check poll waits for server readiness
const serverUrl = `http://${server.host}:${server.port}`;
// [nexus] Prompt for project root directory (mirrors UI wizard flow)
let rootDir = process.cwd();
if (process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.text({
message: "Project root directory:",
initialValue: process.cwd(),
placeholder: process.cwd(),
});
if (!p.isCancel(answer) && answer) {
rootDir = answer;
}
}
bootstrapNexusAgents(serverUrl, rootDir).catch((err: unknown) => {
// [nexus] Bootstrap failures are non-fatal
console.warn("[nexus] Agent bootstrap error:", err instanceof Error ? err.message : String(err));
});
await runCommand({ config: configPath, repair: true, yes: true });
return;
}
@ -480,9 +653,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
p.log.info(
[
"Bootstrap CEO invite will be created after the server starts.",
`Next: ${pc.cyan("paperclipai run")}`,
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
`Bootstrap ${VOCAB.ceo} invite will be created after the server starts.`, // [nexus]
`Next: ${pc.cyan("nexus run")}`, // [nexus]
`Then: ${pc.cyan("nexus auth bootstrap-ceo")}`, // [nexus]
].join("\n"),
);
}

View file

@ -1,4 +1,5 @@
import fs from "node:fs";
import { VOCAB } from "@paperclipai/branding";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
@ -78,7 +79,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
process.exit(1);
}
p.log.step("Starting Paperclip server...");
p.log.step(`Starting ${VOCAB.appName} server...`);
const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
@ -165,13 +166,13 @@ async function importServerEntry(): Promise<StartedServer> {
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
if (isModuleNotFoundError(err) && missingServerEntrypoint) {
throw new Error(
`Could not locate a Paperclip server entrypoint.\n` +
`Could not locate a ${VOCAB.appName} server entrypoint.\n` +
`Tried: ${devEntry}, @paperclipai/server\n` +
`${formatError(err)}`,
);
}
throw new Error(
`Paperclip server failed to start.\n` +
`${VOCAB.appName} server failed to start.\n` +
`${formatError(err)}`,
);
}

View file

@ -49,7 +49,7 @@ import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, r
import { expandHomePrefix } from "../config/home.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printNexusCliBanner } from "../utils/banner.js";
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
import {
buildWorktreeConfig,
@ -77,6 +77,7 @@ import {
type PlannedIssueDocumentMerge,
type PlannedIssueInsert,
} from "./worktree-merge-history-lib.js";
import { VOCAB } from "@paperclipai/branding";
type WorktreeInitOptions = {
name?: string;
@ -1046,13 +1047,13 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
await runWorktreeInit(opts);
}
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
const name = resolveWorktreeMakeName(nameArg);
@ -1248,7 +1249,7 @@ function worktreePathHasUncommittedChanges(worktreePath: string): boolean {
}
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
printPaperclipCliBanner();
printNexusCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
const name = resolveWorktreeMakeName(nameArg);
@ -1538,7 +1539,7 @@ async function resolveMergeCompany(input: {
}
if (shared.length === 0) {
throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match.");
throw new Error(`Source and target databases do not share a ${VOCAB.company.toLowerCase()} id. Pass --company explicitly once both sides match.`);
}
const options = shared
@ -2644,7 +2645,7 @@ export function registerWorktreeCommands(program: Command): void {
.argument("[source]", "Optional source worktree path, directory name, or branch name (back-compat alias for --from)")
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
.option("--company <id-or-prefix>", "Shared company id or issue prefix inside the chosen source/target instances")
.option("--company <id-or-prefix>", `Shared ${VOCAB.company.toLowerCase()} id or issue prefix inside the chosen source/target instances`)
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
.option("--apply", "Apply the import after previewing the plan", false)
.option("--dry", "Preview only and do not import anything", false)

View file

@ -1,10 +1,33 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
// [nexus] Read ~/.nexus pointer file for custom home directory
function resolveNexusPointerFile(): string | null {
const pointerPath = path.resolve(os.homedir(), ".nexus");
try {
const raw = fs.readFileSync(pointerPath, "utf-8").trim();
if (raw.length > 0) {
// Inline tilde expansion (expandHomePrefix is defined later in this file)
const expanded = raw === "~" ? os.homedir()
: raw.startsWith("~/") ? path.resolve(os.homedir(), raw.slice(2))
: raw;
return path.resolve(expanded);
}
} catch {
// ~/.nexus does not exist or is unreadable — fall through
}
return null;
}
export function resolvePaperclipHomeDir(): string {
// [nexus] Pointer-file: ~/.nexus overrides all other home resolution
const nexusRoot = resolveNexusPointerFile();
if (nexusRoot) return nexusRoot;
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");

View file

@ -20,14 +20,15 @@ import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
import { VOCAB } from "@paperclipai/branding"; // [nexus]
const program = new Command();
const DATA_DIR_OPTION_HELP =
"Paperclip data directory root (isolates state from ~/.paperclip)";
`${VOCAB.appName} data directory root (isolates state from ~/.nexus)`; // [nexus]
program
.name("paperclipai")
.description("Paperclip CLI — setup, diagnose, and configure your instance")
.description(`${VOCAB.appName} CLI — setup, diagnose, and configure your instance`) // [nexus]
.version("0.2.7");
program.hook("preAction", (_thisCommand, actionCommand) => {
@ -46,12 +47,12 @@ program
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
.option("--run", "Start Paperclip immediately after saving config", false)
.option("--run", `Start ${VOCAB.appName} immediately after saving config`, false) // [nexus]
.action(onboard);
program
.command("doctor")
.description("Run diagnostic checks on your Paperclip setup")
.description(`Run diagnostic checks on your ${VOCAB.appName} setup`) // [nexus]
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--repair", "Attempt to repair issues automatically")
@ -83,7 +84,7 @@ program
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--dir <path>", "Backup output directory (overrides config)")
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
.option("--filename-prefix <prefix>", "Backup filename prefix", "nexus") // [nexus]
.option("--json", "Print backup metadata as JSON")
.action(async (opts) => {
await dbBackupCommand(opts);
@ -99,7 +100,7 @@ program
program
.command("run")
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
.description(`Bootstrap local setup (onboard + doctor) and run ${VOCAB.appName}`) // [nexus]
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-i, --instance <id>", "Local instance id (default: default)")
@ -117,7 +118,7 @@ heartbeat
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "Base URL for the Paperclip server API")
.option("--api-base <url>", `Base URL for the ${VOCAB.appName} server API`) // [nexus]
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
.option(
"--source <source>",

View file

@ -1,20 +1,23 @@
import pc from "picocolors";
const PAPERCLIP_ART = [
"██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ",
"██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗",
"██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝",
"██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ",
"██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ",
"╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ",
// [nexus] replaced PAPERCLIP_ART with NEXUS_ART
const NEXUS_ART = [
"███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗",
"████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝",
"██╔██╗ ██║█████╗ ╚███╔╝ ██║ ██║███████╗",
"██║╚██╗██║██╔══╝ ██╔██╗ ██║ ██║╚════██║",
"██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║",
"╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
] as const;
const TAGLINE = "Open-source orchestration for zero-human companies";
// [nexus] updated tagline
const TAGLINE = "Open-source orchestration for your agents";
export function printPaperclipCliBanner(): void {
// [nexus] renamed from printPaperclipCliBanner
export function printNexusCliBanner(): void {
const lines = [
"",
...PAPERCLIP_ART.map((line) => pc.cyan(line)),
...NEXUS_ART.map((line) => pc.cyan(line)),
pc.blue(" ───────────────────────────────────────────────────────"),
pc.bold(pc.white(` ${TAGLINE}`)),
"",

View file

@ -39,6 +39,17 @@ This starts:
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server.
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
Inspect or stop the current repo's managed dev runner:
```sh
pnpm dev:list
pnpm dev:stop
```
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
Tailscale/private-auth dev mode:
@ -134,6 +145,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins
- `~/.paperclip/instances/default/companies/<company-id>/codex-home`
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.

View file

@ -76,6 +76,45 @@ The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/genera
After packing or publishing, `postpack` restores the development manifest automatically.
### Manual first publish for `@paperclipai/ui`
If you need to publish only the UI package once by hand, use the real package name:
- `@paperclipai/ui`
Recommended flow from the repo root:
```bash
# optional sanity check: this 404s until the first publish exists
npm view @paperclipai/ui version
# make sure the dist payload is fresh
pnpm --filter @paperclipai/ui build
# confirm your local npm auth before the real publish
npm whoami
# safe preview of the exact publish payload
cd ui
pnpm publish --dry-run --no-git-checks --access public
# real publish
pnpm publish --no-git-checks --access public
```
Notes:
- Publish from `ui/`, not the repo root.
- `prepack` automatically rewrites `ui/package.json` to the lean publish manifest, and `postpack` restores the dev manifest after the command finishes.
- If `npm view @paperclipai/ui version` already returns the same version that is in [`ui/package.json`](../ui/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
If the first real publish returns npm `E404`, check npm-side prerequisites before retrying:
- `npm whoami` must succeed first. An expired or missing npm login will block the publish.
- For an organization-scoped package like `@paperclipai/ui`, the `paperclipai` npm organization must exist and the publisher must be a member with permission to publish to that scope.
- The initial publish must include `--access public` for a public scoped package.
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
## Version formats
Paperclip uses calendar versions:

29
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,29 @@
#!/bin/sh
set -e
# Capture runtime UID/GID from environment variables, defaulting to 1000
PUID=${USER_UID:-1000}
PGID=${USER_GID:-1000}
# Adjust the node user's UID/GID if they differ from the runtime request
# and fix volume ownership only when a remap is needed
changed=0
if [ "$(id -u node)" -ne "$PUID" ]; then
echo "Updating node UID to $PUID"
usermod -o -u "$PUID" node
changed=1
fi
if [ "$(id -g node)" -ne "$PGID" ]; then
echo "Updating node GID to $PGID"
groupmod -o -g "$PGID" node
usermod -g "$PGID" node
changed=1
fi
if [ "$changed" = "1" ]; then
chown -R node:node /paperclip
fi
exec gosu node "$@"

View file

@ -20,9 +20,12 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| Hermes Local | `hermes_local` | Runs Hermes CLI locally |
| Cursor | `cursor` | Runs Cursor in background mode |
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
@ -55,7 +58,7 @@ Three registries consume these modules:
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local`
- **Need to run a script or command?** Use `process`
- **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)

View file

@ -33,6 +33,8 @@ Interactive first-time setup:
pnpm paperclipai onboard
```
If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install.
First prompt:
1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets)
@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen):
pnpm paperclipai onboard --yes
```
On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup.
## `paperclipai doctor`
Health checks with optional auto-repair:

View file

@ -46,6 +46,8 @@
"guides/board-operator/managing-agents",
"guides/board-operator/org-structure",
"guides/board-operator/managing-tasks",
"guides/board-operator/execution-workspaces-and-runtime-services",
"guides/board-operator/delegation",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log",

View file

@ -0,0 +1,122 @@
---
title: How Delegation Works
summary: How the CEO breaks down goals into tasks and assigns them to agents
---
Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator.
## The Delegation Lifecycle
When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team:
```
You set a company goal
→ CEO wakes up on heartbeat
→ CEO proposes a strategy (creates an approval for you)
→ You approve the strategy
→ CEO breaks goals into tasks and assigns them to reports
→ Reports wake up (heartbeat triggered by assignment)
→ Reports execute work and update task status
→ CEO monitors progress, unblocks, and escalates
→ You see results in the dashboard and activity log
```
Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening.
## What You Need to Do
Your role is strategic oversight, not task management. Here's what the delegation model expects from you:
1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better.
2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions.
3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving.
4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates.
5. **Intervene only when things stall.** If progress stops, check these in order:
- Is an approval pending in your queue?
- Is an agent paused or in an error state?
- Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)?
## What the CEO Does Automatically
You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO:
- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria
- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO)
- **Creates subtasks** when work needs to be decomposed further
- **Hires new agents** when the team lacks capacity for a goal (subject to your approval)
- **Monitors progress** on each heartbeat, checking task status and unblocking reports
- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity
## Common Delegation Patterns
### Flat Hierarchy (Small Teams)
For small companies with 3-5 agents, the CEO delegates directly to each report:
```
CEO
├── CTO (engineering tasks)
├── CMO (marketing tasks)
└── Designer (design tasks)
```
The CEO assigns tasks directly. Each agent works independently and reports status back.
### Three-Level Hierarchy (Larger Teams)
For larger organizations, managers delegate further down the chain:
```
CEO
├── CTO
│ ├── Backend Engineer
│ └── Frontend Engineer
└── CMO
└── Content Writer
```
The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically.
### Hire-on-Demand
The CEO can start as the only agent and hire as work requires:
1. You set a goal that needs engineering work
2. The CEO proposes a strategy that includes hiring a CTO
3. You approve the hire
4. The CEO assigns engineering tasks to the new CTO
5. As scope grows, the CTO may request to hire engineers
This pattern lets you start small and scale the team based on actual work, not upfront planning.
## Troubleshooting
### "Why isn't the CEO delegating?"
If you've set a goal but nothing is happening, check these common causes:
| Check | What to look for |
|-------|-----------------|
| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. |
| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. |
| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. |
| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. |
| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. |
| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. |
### "Do I have to tell the CEO to engage engineering and marketing?"
**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment.
### "A task seems stuck"
If a specific task isn't progressing:
1. Check the task's comment thread — the assigned agent may have posted a blocker
2. Check if the task is in `blocked` status — read the blocker comment to understand why
3. Check the assigned agent's status — it may be paused or over budget
4. If the agent is stuck, you can reassign the task or add a comment with guidance

View file

@ -0,0 +1,68 @@
---
title: Execution Workspaces And Runtime Services
summary: How project runtime configuration, execution workspaces, and issue runs fit together
---
This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip.
## Project runtime configuration
You can define how to run a project on the project workspace itself.
- Project workspace runtime config describes how to run services for that project checkout.
- This is the default runtime configuration that child execution workspaces may inherit.
- Defining the config does not start anything by itself.
## Manual runtime control
Runtime services are manually controlled from the UI.
- Project workspace runtime services are started and stopped from the project workspace UI.
- Execution workspace runtime services are started and stopped from the execution workspace UI.
- Paperclip does not automatically start or stop these runtime services as part of issue execution.
- Paperclip also does not automatically restart workspace runtime services on server boot.
## Execution workspace inheritance
Execution workspaces isolate code and runtime state from the project primary workspace.
- An isolated execution workspace has its own checkout path, branch, and local runtime instance.
- The runtime configuration may inherit from the linked project workspace by default.
- The execution workspace may override that runtime configuration with its own workspace-specific settings.
- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace.
## Issues and execution workspaces
Issues are attached to execution workspace behavior, not to automatic runtime management.
- An issue may create a new execution workspace when you choose an isolated workspace mode.
- An issue may reuse an existing execution workspace when you choose reuse.
- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services.
- Assigning or running an issue does not automatically start or stop runtime services for that workspace.
## Execution workspace lifecycle
Execution workspaces are durable until a human closes them.
- The UI can archive an execution workspace.
- Closing an execution workspace stops its runtime services and cleans up its workspace artifacts when allowed.
- Shared workspaces that point at the project primary checkout are treated more conservatively during cleanup than disposable isolated workspaces.
## Resolved workspace logic during heartbeat runs
Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control.
1. Heartbeat resolves a base workspace for the run.
2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed.
3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings.
4. Heartbeat passes the resolved code workspace to the agent run.
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
## Current implementation guarantees
With the current implementation:
- Project workspace runtime config is the fallback for execution workspace UI controls.
- Execution workspace runtime overrides are stored on the execution workspace.
- Heartbeat runs do not auto-start workspace runtime services.
- Server startup does not auto-restart workspace runtime services.

View file

@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
Common adapter choices:
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
- `openclaw` / `http` for webhook-based external agents
- `openclaw_gateway` / `http` for webhook-based external agents
- `process` for generic local command execution
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).

View file

@ -1,9 +1,9 @@
---
title: Core Concepts
summary: Companies, agents, issues, heartbeats, and governance
summary: Companies, agents, issues, delegation, heartbeats, and governance
---
Paperclip organizes autonomous AI work around five key concepts.
Paperclip organizes autonomous AI work around six key concepts.
## Company
@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`.
The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`.
## Delegation
The CEO is the primary delegator. When you set company goals, the CEO:
1. Creates a strategy and submits it for your approval
2. Breaks approved goals into tasks
3. Assigns tasks to agents based on their role and capabilities
4. Hires new agents when needed (subject to your approval)
You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle.
## Heartbeats
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.

View file

@ -13,6 +13,8 @@ npx paperclipai onboard --yes
This walks you through setup, configures your environment, and gets Paperclip running.
If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings.
To start Paperclip again later:
```sh

View file

@ -3,9 +3,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
"dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev",
"dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list",
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"build": "pnpm -r build",
@ -32,7 +34,8 @@
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
},
"devDependencies": {
"@playwright/test": "^1.58.2",

View file

@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record<string, string>): Record<string, st
return redacted;
}
export function buildInvocationEnvForLogs(
env: Record<string, string>,
options: {
runtimeEnv?: NodeJS.ProcessEnv | Record<string, string>;
includeRuntimeKeys?: string[];
resolvedCommand?: string | null;
resolvedCommandEnvKey?: string;
} = {},
): Record<string, string> {
const merged: Record<string, string> = { ...env };
const runtimeEnv = options.runtimeEnv ?? {};
for (const key of options.includeRuntimeKeys ?? []) {
if (key in merged) continue;
const value = runtimeEnv[key];
if (typeof value !== "string" || value.length === 0) continue;
merged[key] = value;
}
const resolvedCommand = options.resolvedCommand?.trim();
if (resolvedCommand) {
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand;
}
return redactEnvForLogs(merged);
}
export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record<string, string> {
const resolveHostForUrl = (rawHost: string): string => {
const host = rawHost.trim();
@ -269,6 +296,10 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc
return null;
}
export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
return (await resolveCommandPath(command, cwd, env)) ?? command;
}
function quoteForCmd(arg: string) {
if (!arg.length) return '""';
const escaped = arg.replace(/"/g, '""');

View file

@ -287,6 +287,12 @@ export interface ServerAdapterModule {
* without knowing provider-specific credential paths or API shapes.
*/
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
/**
* Optional: detect the currently configured model from local config files.
* Returns the detected model/provider and the config source, or null if
* the adapter does not support detection or no config is found.
*/
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
}
// ---------------------------------------------------------------------------

View file

@ -17,6 +17,27 @@ function asErrorText(value: unknown): string {
}
}
function printToolResult(block: Record<string, unknown>): void {
const isError = block.is_error === true;
let text = "";
if (typeof block.content === "string") {
text = block.content;
} else if (Array.isArray(block.content)) {
const parts: string[] = [];
for (const part of block.content) {
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
const record = part as Record<string, unknown>;
if (typeof record.text === "string") parts.push(record.text);
}
text = parts.join("\n");
}
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
if (text) {
console.log((isError ? pc.red : pc.gray)(text));
}
}
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : "";
if (text) console.log(pc.green(`assistant: ${text}`));
} else if (blockType === "thinking") {
const text = typeof block.thinking === "string" ? block.thinking : "";
if (text) console.log(pc.gray(`thinking: ${text}`));
} else if (blockType === "tool_use") {
const name = typeof block.name === "string" ? block.name : "unknown";
console.log(pc.yellow(`tool_call: ${name}`));
@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
return;
}
if (type === "user") {
const message =
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
? (parsed.message as Record<string, unknown>)
: {};
const content = Array.isArray(message.content) ? message.content : [];
for (const blockRaw of content) {
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
const block = blockRaw as Record<string, unknown>;
if (typeof block.type === "string" && block.type === "tool_result") {
printToolResult(block);
}
}
return;
}
if (type === "result") {
const usage =
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)

View file

@ -26,7 +26,7 @@ Core fields:
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
Operational fields:
- timeoutSec (number, optional): run timeout in seconds

View file

@ -14,10 +14,11 @@ import {
buildPaperclipEnv,
readPaperclipRuntimeSkillEntries,
joinPromptSections,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
resolveCommandForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@ -68,11 +69,13 @@ interface ClaudeExecutionInput {
interface ClaudeRuntimeConfig {
command: string;
resolvedCommand: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
loggedEnv: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -247,11 +256,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
return {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@ -324,11 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@ -440,11 +453,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "claude_local",
command,
command: resolvedCommand,
cwd,
commandArgs: args,
commandNotes,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -24,7 +24,7 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
@ -32,7 +32,7 @@ Core fields:
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
Operational fields:
- timeoutSec (number, optional): run timeout in seconds

View file

@ -9,12 +9,13 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
renderTemplate,
joinPromptSections,
@ -383,6 +384,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -490,14 +497,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -0,0 +1,85 @@
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const cp = await importOriginal<typeof import("node:child_process")>();
return {
...cp,
spawn: (...args: Parameters<typeof cp.spawn>) => mockSpawn(...args) as ReturnType<typeof cp.spawn>,
};
});
import { getQuotaWindows } from "./quota.js";
function createChildThatErrorsOnMicrotask(err: Error): ChildProcess {
const child = new EventEmitter() as ChildProcess;
const stream = Object.assign(new EventEmitter(), {
setEncoding: () => {},
});
Object.assign(child, {
stdout: stream,
stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn(),
});
queueMicrotask(() => {
child.emit("error", err);
});
return child;
}
describe("CodexRpcClient spawn failures", () => {
let previousCodexHome: string | undefined;
let isolatedCodexHome: string | undefined;
beforeEach(() => {
mockSpawn.mockReset();
// After the RPC path fails, getQuotaWindows() calls readCodexToken() which
// reads $CODEX_HOME/auth.json (default ~/.codex). Point CODEX_HOME at an
// empty temp directory so we never hit real host auth or the WHAM network.
previousCodexHome = process.env.CODEX_HOME;
isolatedCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-spawn-test-"));
process.env.CODEX_HOME = isolatedCodexHome;
});
afterEach(() => {
if (isolatedCodexHome) {
try {
fs.rmSync(isolatedCodexHome, { recursive: true, force: true });
} catch {
/* ignore */
}
isolatedCodexHome = undefined;
}
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
});
it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => {
const enoent = Object.assign(new Error("spawn codex ENOENT"), {
code: "ENOENT",
errno: -2,
syscall: "spawn codex",
path: "codex",
});
mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent));
const result = await getQuotaWindows();
expect(result.ok).toBe(false);
expect(result.windows).toEqual([]);
expect(result.error).toContain("Codex app-server");
expect(result.error).toContain("spawn codex ENOENT");
});
});

View file

@ -432,6 +432,13 @@ class CodexRpcClient {
}
this.pending.clear();
});
this.proc.on("error", (err: Error) => {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(err);
}
this.pending.clear();
});
}
private onStdout(chunk: string) {

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View file

@ -9,12 +9,13 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -383,11 +390,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "cursor",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -10,16 +10,17 @@ import {
asString,
asStringArray,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@ -220,6 +221,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -333,13 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "gemini_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -31,7 +31,7 @@ Gateway connect identity fields:
Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
- workspaceRuntime (object, optional): reserved workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
@ -45,7 +45,7 @@ Standard outbound payload additions:
- paperclip (object): standardized Paperclip context added to every gateway agent request
- paperclip.workspace (object, optional): resolved execution workspace for this run
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
- paperclip.workspaceRuntime (object, optional): reserved workspace runtime metadata when explicitly supplied outside normal heartbeat execution
Standard result metadata supported:
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports

View file

@ -1,7 +1,15 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const models: Array<{ id: string; label: string }> = [];
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const agentConfigurationDoc = `# opencode_local agent configuration
@ -21,7 +29,7 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max)
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"

View file

@ -10,11 +10,12 @@ import {
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
resolveCommandForLogs,
renderTemplate,
runChildProcess,
readPaperclipRuntimeSkillEntries,
@ -186,6 +187,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
await ensureOpenCodeModelConfiguredAndAvailable({
model,
@ -298,11 +305,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(preparedRuntimeConfig.env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -10,12 +10,13 @@ import {
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
@ -204,6 +205,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
// Validate model is available before execution
await ensurePiModelConfiguredAndAvailable({
@ -356,11 +363,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "pi_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt: userPrompt,
promptMetrics,
context,

View file

@ -0,0 +1,34 @@
{
"name": "@paperclipai/branding",
"version": "0.1.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1 @@
export { VOCAB, type VocabKey } from "./vocab.js";

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { VOCAB } from "./vocab.js";
describe("VOCAB", () => {
it("maps company to Workspace", () => {
expect(VOCAB.company).toBe("Workspace");
});
it("maps companies to Workspaces", () => {
expect(VOCAB.companies).toBe("Workspaces");
});
it("maps ceo to Project Manager", () => {
expect(VOCAB.ceo).toBe("Project Manager");
});
it("maps board to Owner", () => {
expect(VOCAB.board).toBe("Owner");
});
it("maps hire to Add", () => {
expect(VOCAB.hire).toBe("Add");
});
it("maps fire to Remove", () => {
expect(VOCAB.fire).toBe("Remove");
});
it("has appName as Nexus", () => {
expect(VOCAB.appName).toBe("Nexus");
});
it("has a non-empty tagline", () => {
expect(VOCAB.tagline).toBe("Open-source orchestration for your agents");
});
it("all values are non-empty strings", () => {
for (const [key, value] of Object.entries(VOCAB)) {
expect(typeof value, `key "${key}" should be a string`).toBe("string");
expect(value.length, `key "${key}" should be non-empty`).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,15 @@
export const VOCAB = {
// Entity renames (display only — code identifiers unchanged)
company: "Workspace",
companies: "Workspaces",
ceo: "Project Manager",
board: "Owner",
hire: "Add",
fire: "Remove",
// Brand name
appName: "Nexus",
tagline: "Open-source orchestration for your agents",
} as const;
export type VocabKey = keyof typeof VOCAB;

View file

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

View file

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

View file

@ -169,4 +169,76 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays migration 0046 safely when document revision columns already exist",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const smoothSentinelsHash = await migrationHash("0046_smooth_sentinels.sql");
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${smoothSentinelsHash}'`,
);
const columns = await sql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
`
SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'document_revisions'
AND column_name IN ('title', 'format')
ORDER BY column_name
`,
);
expect(columns).toHaveLength(2);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0046_smooth_sentinels.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
`
SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'document_revisions'
AND column_name IN ('title', 'format')
ORDER BY column_name
`,
);
expect(columns).toEqual([
expect.objectContaining({
column_name: "format",
is_nullable: "NO",
}),
expect.objectContaining({
column_name: "title",
is_nullable: "YES",
}),
]);
expect(columns[0]?.column_default).toContain("'markdown'");
} finally {
await verifySql.end();
}
},
20_000,
);
});

View file

@ -0,0 +1,11 @@
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "title" text;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "format" text;--> statement-breakpoint
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET DEFAULT 'markdown';
--> statement-breakpoint
UPDATE "document_revisions" AS "dr"
SET
"title" = COALESCE("dr"."title", "d"."title"),
"format" = COALESCE("dr"."format", "d"."format", 'markdown')
FROM "documents" AS "d"
WHERE "d"."id" = "dr"."document_id";--> statement-breakpoint
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -323,6 +323,13 @@
"when": 1774530504348,
"tag": "0045_workable_shockwave",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1774960197878,
"tag": "0046_smooth_sentinels",
"breakpoints": true
}
]
}

View file

@ -10,6 +10,8 @@ export const documentRevisions = pgTable(
companyId: uuid("company_id").notNull().references(() => companies.id),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
title: text("title"),
format: text("format").notNull().default("markdown"),
body: text("body").notNull(),
changeSummary: text("change_summary"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),

View file

@ -579,6 +579,7 @@ export interface WorkerToHostMethods {
projectId?: string;
goalId?: string;
parentId?: string;
inheritExecutionWorkspaceFromIssueId?: string;
title: string;
description?: string;
priority?: string;

View file

@ -872,6 +872,7 @@ export interface PluginIssuesClient {
projectId?: string;
goalId?: string;
parentId?: string;
inheritExecutionWorkspaceFromIssueId?: string;
title: string;
description?: string;
priority?: Issue["priority"];

View file

@ -590,6 +590,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
projectId: input.projectId,
goalId: input.goalId,
parentId: input.parentId,
inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId,
title: input.title,
description: input.description,
priority: input.priority,

View file

@ -50,7 +50,7 @@ export const AGENT_ROLES = [
export type AgentRole = (typeof AGENT_ROLES)[number];
export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
ceo: "CEO",
ceo: "Project Manager", // [nexus] was: "CEO"
cto: "CTO",
cmo: "CMO",
cfo: "CFO",
@ -60,7 +60,7 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
qa: "QA",
devops: "DevOps",
researcher: "Researcher",
general: "General",
general: "Generalist", // [nexus] was: "General"
};
export const AGENT_ICON_NAMES = [
@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [
] as const;
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
export const INBOX_MINE_ISSUE_STATUSES = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
] as const;
export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(",");
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];

View file

@ -9,6 +9,8 @@ export {
AGENT_ROLE_LABELS,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS,
GOAL_LEVELS,
@ -186,10 +188,19 @@ export type {
ProjectGoalRef,
ProjectWorkspace,
ExecutionWorkspace,
ExecutionWorkspaceConfig,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseActionKind,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseLinkedIssue,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
WorkspaceRuntimeDesiredState,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,
@ -344,6 +355,7 @@ export {
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
@ -356,6 +368,7 @@ export {
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,
@ -384,9 +397,16 @@ export {
issueWorkProductReviewStateSchema,
updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema,
executionWorkspaceCloseActionKindSchema,
executionWorkspaceCloseActionSchema,
executionWorkspaceCloseGitReadinessSchema,
executionWorkspaceCloseLinkedIssueSchema,
executionWorkspaceCloseReadinessSchema,
executionWorkspaceCloseReadinessStateSchema,
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
restoreIssueDocumentRevisionSchema,
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
@ -399,6 +419,7 @@ export {
type UpdateExecutionWorkspace,
type IssueDocumentFormat,
type UpsertIssueDocument,
type RestoreIssueDocumentRevision,
createGoalSchema,
updateGoalSchema,
type CreateGoal,

View file

@ -50,7 +50,16 @@ export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
ExecutionWorkspace,
ExecutionWorkspaceConfig,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseActionKind,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseLinkedIssue,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
WorkspaceRuntimeDesiredState,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,

View file

@ -81,6 +81,8 @@ export interface DocumentRevision {
issueId: string;
key: string;
revisionNumber: number;
title: string | null;
format: DocumentFormat;
body: string;
changeSummary: string | null;
createdByAgentId: string | null;

View file

@ -1,5 +1,9 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
} from "./workspace-runtime.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
@ -26,6 +30,7 @@ export interface ProjectWorkspace {
remoteWorkspaceRef: string | null;
sharedWorkspaceKey: string | null;
metadata: Record<string, unknown> | null;
runtimeConfig: ProjectWorkspaceRuntimeConfig | null;
isPrimary: boolean;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date;

View file

@ -31,6 +31,22 @@ export type ExecutionWorkspaceStatus =
| "archived"
| "cleanup_failed";
export type ExecutionWorkspaceCloseReadinessState =
| "ready"
| "ready_with_warnings"
| "blocked";
export type ExecutionWorkspaceCloseActionKind =
| "archive_record"
| "stop_runtime_services"
| "cleanup_command"
| "teardown_command"
| "git_worktree_remove"
| "git_branch_delete"
| "remove_local_directory";
export type WorkspaceRuntimeDesiredState = "running" | "stopped";
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
baseRef?: string | null;
@ -40,6 +56,63 @@ export interface ExecutionWorkspaceStrategy {
teardownCommand?: string | null;
}
export interface ExecutionWorkspaceConfig {
provisionCommand: string | null;
teardownCommand: string | null;
cleanupCommand: string | null;
workspaceRuntime: Record<string, unknown> | null;
desiredState: WorkspaceRuntimeDesiredState | null;
}
export interface ProjectWorkspaceRuntimeConfig {
workspaceRuntime: Record<string, unknown> | null;
desiredState: WorkspaceRuntimeDesiredState | null;
}
export interface ExecutionWorkspaceCloseAction {
kind: ExecutionWorkspaceCloseActionKind;
label: string;
description: string;
command: string | null;
}
export interface ExecutionWorkspaceCloseLinkedIssue {
id: string;
identifier: string | null;
title: string;
status: string;
isTerminal: boolean;
}
export interface ExecutionWorkspaceCloseGitReadiness {
repoRoot: string | null;
workspacePath: string | null;
branchName: string | null;
baseRef: string | null;
hasDirtyTrackedFiles: boolean;
hasUntrackedFiles: boolean;
dirtyEntryCount: number;
untrackedEntryCount: number;
aheadCount: number | null;
behindCount: number | null;
isMergedIntoBase: boolean | null;
createdByRuntime: boolean;
}
export interface ExecutionWorkspaceCloseReadiness {
workspaceId: string;
state: ExecutionWorkspaceCloseReadinessState;
blockingReasons: string[];
warnings: string[];
linkedIssues: ExecutionWorkspaceCloseLinkedIssue[];
plannedActions: ExecutionWorkspaceCloseAction[];
isDestructiveCloseAllowed: boolean;
isSharedWorkspace: boolean;
isProjectPrimaryWorkspace: boolean;
git: ExecutionWorkspaceCloseGitReadiness | null;
runtimeServices: WorkspaceRuntimeService[];
}
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
@ -81,7 +154,9 @@ export interface ExecutionWorkspace {
closedAt: Date | null;
cleanupEligibleAt: Date | null;
cleanupReason: string | null;
config: ExecutionWorkspaceConfig | null;
metadata: Record<string, unknown> | null;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date;
updatedAt: Date;
}

View file

@ -4,6 +4,7 @@ import {
AGENT_ICON_NAMES,
AGENT_ROLES,
AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js";
import { envConfigSchema } from "./secret.js";
@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
export const agentMineInboxQuerySchema = z.object({
userId: z.string().trim().min(1),
status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER),
});
export type AgentMineInboxQuery = z.infer<typeof agentMineInboxQuerySchema>;
export const wakeAgentSchema = z.object({
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),

View file

@ -8,10 +8,115 @@ export const executionWorkspaceStatusSchema = z.enum([
"cleanup_failed",
]);
export const executionWorkspaceConfigSchema = z.object({
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
}).strict();
export const executionWorkspaceCloseReadinessStateSchema = z.enum([
"ready",
"ready_with_warnings",
"blocked",
]);
export const executionWorkspaceCloseActionKindSchema = z.enum([
"archive_record",
"stop_runtime_services",
"cleanup_command",
"teardown_command",
"git_worktree_remove",
"git_branch_delete",
"remove_local_directory",
]);
export const executionWorkspaceCloseActionSchema = z.object({
kind: executionWorkspaceCloseActionKindSchema,
label: z.string(),
description: z.string(),
command: z.string().nullable(),
}).strict();
export const executionWorkspaceCloseLinkedIssueSchema = z.object({
id: z.string().uuid(),
identifier: z.string().nullable(),
title: z.string(),
status: z.string(),
isTerminal: z.boolean(),
}).strict();
export const executionWorkspaceCloseGitReadinessSchema = z.object({
repoRoot: z.string().nullable(),
workspacePath: z.string().nullable(),
branchName: z.string().nullable(),
baseRef: z.string().nullable(),
hasDirtyTrackedFiles: z.boolean(),
hasUntrackedFiles: z.boolean(),
dirtyEntryCount: z.number().int().nonnegative(),
untrackedEntryCount: z.number().int().nonnegative(),
aheadCount: z.number().int().nonnegative().nullable(),
behindCount: z.number().int().nonnegative().nullable(),
isMergedIntoBase: z.boolean().nullable(),
createdByRuntime: z.boolean(),
}).strict();
export const workspaceRuntimeServiceSchema = z.object({
id: z.string(),
companyId: z.string().uuid(),
projectId: z.string().uuid().nullable(),
projectWorkspaceId: z.string().uuid().nullable(),
executionWorkspaceId: z.string().uuid().nullable(),
issueId: z.string().uuid().nullable(),
scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]),
scopeId: z.string().nullable(),
serviceName: z.string(),
status: z.enum(["starting", "running", "stopped", "failed"]),
lifecycle: z.enum(["shared", "ephemeral"]),
reuseKey: z.string().nullable(),
command: z.string().nullable(),
cwd: z.string().nullable(),
port: z.number().int().nullable(),
url: z.string().nullable(),
provider: z.enum(["local_process", "adapter_managed"]),
providerRef: z.string().nullable(),
ownerAgentId: z.string().uuid().nullable(),
startedByRunId: z.string().uuid().nullable(),
lastUsedAt: z.coerce.date(),
startedAt: z.coerce.date(),
stoppedAt: z.coerce.date().nullable(),
stopPolicy: z.record(z.unknown()).nullable(),
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}).strict();
export const executionWorkspaceCloseReadinessSchema = z.object({
workspaceId: z.string().uuid(),
state: executionWorkspaceCloseReadinessStateSchema,
blockingReasons: z.array(z.string()),
warnings: z.array(z.string()),
linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema),
plannedActions: z.array(executionWorkspaceCloseActionSchema),
isDestructiveCloseAllowed: z.boolean(),
isSharedWorkspace: z.boolean(),
isProjectPrimaryWorkspace: z.boolean(),
git: executionWorkspaceCloseGitReadinessSchema.nullable(),
runtimeServices: z.array(workspaceRuntimeServiceSchema),
}).strict();
export const updateExecutionWorkspaceSchema = z.object({
name: z.string().min(1).optional(),
cwd: z.string().optional().nullable(),
repoUrl: z.string().optional().nullable(),
baseRef: z.string().optional().nullable(),
branchName: z.string().optional().nullable(),
providerRef: z.string().optional().nullable(),
status: executionWorkspaceStatusSchema.optional(),
cleanupEligibleAt: z.string().datetime().optional().nullable(),
cleanupReason: z.string().optional().nullable(),
config: executionWorkspaceConfigSchema.optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
}).strict();

View file

@ -85,6 +85,7 @@ export {
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
@ -97,6 +98,7 @@ export {
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,
@ -109,6 +111,7 @@ export {
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
projectWorkspaceRuntimeConfigSchema,
type CreateProject,
type UpdateProject,
type CreateProjectWorkspace,
@ -128,6 +131,7 @@ export {
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
restoreIssueDocumentRevisionSchema,
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
@ -138,6 +142,7 @@ export {
type CreateIssueAttachmentMetadata,
type IssueDocumentFormat,
type UpsertIssueDocument,
type RestoreIssueDocumentRevision,
} from "./issue.js";
export {
@ -151,8 +156,15 @@ export {
} from "./work-product.js";
export {
executionWorkspaceConfigSchema,
updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema,
executionWorkspaceCloseActionKindSchema,
executionWorkspaceCloseActionSchema,
executionWorkspaceCloseGitReadinessSchema,
executionWorkspaceCloseLinkedIssueSchema,
executionWorkspaceCloseReadinessSchema,
executionWorkspaceCloseReadinessStateSchema,
type UpdateExecutionWorkspace,
} from "./execution-workspace.js";

View file

@ -32,6 +32,7 @@ export const createIssueSchema = z.object({
projectWorkspaceId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
title: z.string().min(1),
description: z.string().optional().nullable(),
status: z.enum(ISSUE_STATUSES).optional().default("backlog"),
@ -66,6 +67,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(),
reopen: z.boolean().optional(),
interrupt: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(),
});
@ -118,5 +120,8 @@ export const upsertIssueDocumentSchema = z.object({
baseRevisionId: z.string().uuid().nullable().optional(),
});
export const restoreIssueDocumentRevisionSchema = z.object({});
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
export type RestoreIssueDocumentRevision = z.infer<typeof restoreIssueDocumentRevisionSchema>;

View file

@ -27,6 +27,11 @@ export const projectExecutionWorkspacePolicySchema = z
})
.strict();
export const projectWorkspaceRuntimeConfigSchema = z.object({
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
}).strict();
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]);
@ -44,6 +49,7 @@ const projectWorkspaceFields = {
remoteWorkspaceRef: z.string().optional().nullable(),
sharedWorkspaceKey: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(),
};
function validateProjectWorkspace(value: Record<string, unknown>, ctx: z.RefinementCtx) {

316
pnpm-lock.yaml generated
View file

@ -58,6 +58,9 @@ importers:
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
'@paperclipai/branding':
specifier: workspace:*
version: link:../packages/branding
'@paperclipai/db':
specifier: workspace:*
version: link:../packages/db
@ -75,7 +78,7 @@ importers:
version: 17.3.1
drizzle-orm:
specifier: 0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -220,6 +223,12 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/branding:
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/db:
dependencies:
'@paperclipai/shared':
@ -227,7 +236,7 @@ importers:
version: link:../shared
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -440,6 +449,9 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.888.0
version: 3.994.0
'@libsql/client':
specifier: ^0.17.2
version: 0.17.2
'@paperclipai/adapter-claude-local':
specifier: workspace:*
version: link:../packages/adapters/claude-local
@ -481,7 +493,7 @@ importers:
version: 3.0.1(ajv@8.18.0)
better-auth:
specifier: 1.4.18
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0))
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0))
chokidar:
specifier: ^4.0.3
version: 4.0.3
@ -496,7 +508,7 @@ importers:
version: 17.3.1
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
@ -504,8 +516,8 @@ importers:
specifier: ^5.1.0
version: 5.2.1
hermes-paperclip-adapter:
specifier: 0.1.1
version: 0.1.1
specifier: ^0.2.0
version: 0.2.1
jsdom:
specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1)
@ -618,6 +630,9 @@ importers:
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
'@paperclipai/branding':
specifier: workspace:*
version: link:../packages/branding
'@paperclipai/shared':
specifier: workspace:*
version: link:../packages/shared
@ -639,6 +654,12 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
diff:
specifier: ^8.0.4
version: 8.0.4
hermes-paperclip-adapter:
specifier: ^0.2.0
version: 0.2.1
lexical:
specifier: 0.35.0
version: 0.35.0
@ -673,6 +694,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.7
version: 4.1.18(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/node':
specifier: ^25.2.3
version: 25.2.3
@ -2005,6 +2029,63 @@ packages:
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@libsql/client@0.17.2':
resolution: {integrity: sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q==}
'@libsql/core@0.17.2':
resolution: {integrity: sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g==}
'@libsql/darwin-arm64@0.5.29':
resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
cpu: [arm64]
os: [darwin]
'@libsql/darwin-x64@0.5.29':
resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
cpu: [x64]
os: [darwin]
'@libsql/hrana-client@0.9.0':
resolution: {integrity: sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==}
'@libsql/isomorphic-ws@0.1.5':
resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
'@libsql/linux-arm-gnueabihf@0.5.29':
resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm-musleabihf@0.5.29':
resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm64-gnu@0.5.29':
resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
cpu: [arm64]
os: [linux]
'@libsql/linux-arm64-musl@0.5.29':
resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
cpu: [arm64]
os: [linux]
'@libsql/linux-x64-gnu@0.5.29':
resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
cpu: [x64]
os: [linux]
'@libsql/linux-x64-musl@0.5.29':
resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
cpu: [x64]
os: [linux]
'@libsql/win32-x64-msvc@0.5.29':
resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
cpu: [x64]
os: [win32]
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@ -2025,6 +2106,9 @@ packages:
'@mermaid-js/parser@1.0.0':
resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==}
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
@ -2040,8 +2124,8 @@ packages:
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@paperclipai/adapter-utils@0.3.1':
resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==}
'@paperclipai/adapter-utils@2026.325.0':
resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -3378,6 +3462,10 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/diff@8.0.0':
resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -3841,6 +3929,9 @@ packages:
engines: {node: '>=20'}
hasBin: true
cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -4021,6 +4112,10 @@ packages:
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@ -4084,6 +4179,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.0.2:
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -4106,6 +4205,10 @@ packages:
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
engines: {node: '>=0.3.1'}
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dompurify@3.3.2:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
@ -4377,6 +4480,10 @@ packages:
picomatch:
optional: true
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@ -4389,6 +4496,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
@ -4468,8 +4579,8 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hermes-paperclip-adapter@0.1.1:
resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==}
hermes-paperclip-adapter@0.2.1:
resolution: {integrity: sha512-9D4SrmMXm4AhOZ08lnlGCZBzwfRnGjzZjkvPlkRfoPTODM2YeIAaEk+zO0vInh8DI4Qr3ySN8hLFxV1eKRYFaA==}
engines: {node: '>=20.0.0'}
html-encoding-sniffer@6.0.0:
@ -4587,6 +4698,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -4652,6 +4766,10 @@ packages:
engines: {node: '>=16'}
hasBin: true
libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@ -5017,6 +5135,24 @@ packages:
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -5201,6 +5337,9 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -5616,6 +5755,9 @@ packages:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
@ -5894,6 +6036,13 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@ -5906,6 +6055,9 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -7653,6 +7805,68 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@libsql/client@0.17.2':
dependencies:
'@libsql/core': 0.17.2
'@libsql/hrana-client': 0.9.0
js-base64: 3.7.8
libsql: 0.5.29
promise-limit: 2.7.0
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/core@0.17.2':
dependencies:
js-base64: 3.7.8
'@libsql/darwin-arm64@0.5.29':
optional: true
'@libsql/darwin-x64@0.5.29':
optional: true
'@libsql/hrana-client@0.9.0':
dependencies:
'@libsql/isomorphic-ws': 0.1.5
cross-fetch: 4.1.0
js-base64: 3.7.8
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/isomorphic-ws@0.1.5':
dependencies:
'@types/ws': 8.18.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/linux-arm-gnueabihf@0.5.29':
optional: true
'@libsql/linux-arm-musleabihf@0.5.29':
optional: true
'@libsql/linux-arm64-gnu@0.5.29':
optional: true
'@libsql/linux-arm64-musl@0.5.29':
optional: true
'@libsql/linux-x64-gnu@0.5.29':
optional: true
'@libsql/linux-x64-musl@0.5.29':
optional: true
'@libsql/win32-x64-msvc@0.5.29':
optional: true
'@marijn/find-cluster-break@1.0.2': {}
'@mdxeditor/editor@3.52.4(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
@ -7732,6 +7946,8 @@ snapshots:
dependencies:
langium: 4.2.1
'@neon-rs/load@0.0.4': {}
'@noble/ciphers@2.1.1': {}
'@noble/hashes@1.8.0': {}
@ -7740,7 +7956,7 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {}
'@paperclipai/adapter-utils@0.3.1': {}
'@paperclipai/adapter-utils@2026.325.0': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
@ -9208,6 +9424,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/diff@8.0.0':
dependencies:
diff: 8.0.4
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@ -9434,7 +9654,7 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)):
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
@ -9450,7 +9670,7 @@ snapshots:
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
pg: 8.18.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -9657,6 +9877,12 @@ snapshots:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-fetch@4.1.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -9868,6 +10094,8 @@ snapshots:
d3: 7.9.0
lodash-es: 4.17.23
data-uri-to-buffer@4.0.1: {}
data-urls@7.0.0(@noble/hashes@2.0.1):
dependencies:
whatwg-mimetype: 5.0.0
@ -9914,6 +10142,8 @@ snapshots:
dequal@2.0.3: {}
detect-libc@2.0.2: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -9933,6 +10163,8 @@ snapshots:
diff@5.2.2: {}
diff@8.0.4: {}
dompurify@3.3.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -9959,9 +10191,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4):
drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4):
optionalDependencies:
'@electric-sql/pglite': 0.3.15
'@libsql/client': 0.17.2
'@types/react': 19.2.14
kysely: 0.28.11
pg: 8.18.0
@ -10228,6 +10461,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@ -10249,6 +10487,10 @@ snapshots:
format@0.2.2: {}
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
formidable@3.5.4:
dependencies:
'@paralleldrive/cuid2': 2.3.1
@ -10337,9 +10579,9 @@ snapshots:
help-me@5.0.0: {}
hermes-paperclip-adapter@0.1.1:
hermes-paperclip-adapter@0.2.1:
dependencies:
'@paperclipai/adapter-utils': 0.3.1
'@paperclipai/adapter-utils': 2026.325.0
picocolors: 1.1.1
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
@ -10439,6 +10681,8 @@ snapshots:
joycon@3.1.1: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@ -10508,6 +10752,21 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
libsql@0.5.29:
dependencies:
'@neon-rs/load': 0.0.4
detect-libc: 2.0.2
optionalDependencies:
'@libsql/darwin-arm64': 0.5.29
'@libsql/darwin-x64': 0.5.29
'@libsql/linux-arm-gnueabihf': 0.5.29
'@libsql/linux-arm-musleabihf': 0.5.29
'@libsql/linux-arm64-gnu': 0.5.29
'@libsql/linux-arm64-musl': 0.5.29
'@libsql/linux-x64-gnu': 0.5.29
'@libsql/linux-x64-musl': 0.5.29
'@libsql/win32-x64-msvc': 0.5.29
lightningcss-android-arm64@1.30.2:
optional: true
@ -11153,6 +11412,18 @@ snapshots:
next-tick@1.1.0: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-releases@2.0.27: {}
object-assign@4.1.1: {}
@ -11350,6 +11621,8 @@ snapshots:
process-warning@5.0.0: {}
promise-limit@2.7.0: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@ -11893,6 +12166,8 @@ snapshots:
dependencies:
tldts: 7.0.26
tr46@0.0.3: {}
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@ -12241,6 +12516,10 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
@ -12253,6 +12532,11 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

656
scripts/dev-runner.ts Normal file
View file

@ -0,0 +1,656 @@
#!/usr/bin/env -S node --import tsx
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
import {
findAdoptableLocalService,
removeLocalServiceRegistryRecord,
touchLocalServiceRegistryRecord,
writeLocalServiceRegistryRecord,
} from "../server/src/services/local-service-supervisor.ts";
const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3);
const scanIntervalMs = 1500;
const autoRestartPollIntervalMs = 2500;
const gracefulShutdownTimeoutMs = 10_000;
const changedPathSampleLimit = 5;
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
const watchedDirectories = [
"cli",
"scripts",
"server",
"packages/adapter-utils",
"packages/adapters",
"packages/db",
"packages/plugins/sdk",
"packages/shared",
].map((relativePath) => path.join(repoRoot, relativePath));
const watchedFiles = [
".env",
"package.json",
"pnpm-workspace.yaml",
"tsconfig.base.json",
"tsconfig.json",
"vitest.config.ts",
].map((relativePath) => path.join(repoRoot, relativePath));
const ignoredDirectoryNames = new Set([
".git",
".turbo",
".vite",
"coverage",
"dist",
"node_modules",
"ui-dist",
]);
const ignoredRelativePaths = new Set([
".paperclip/dev-server-status.json",
]);
const tailscaleAuthFlagNames = new Set([
"--tailscale-auth",
"--authenticated-private",
]);
let tailscaleAuth = false;
const forwardedArgs: string[] = [];
for (const arg of cliArgs) {
if (tailscaleAuthFlagNames.has(arg)) {
tailscaleAuth = true;
continue;
}
forwardedArgs.push(arg);
}
if (process.env.npm_config_tailscale_auth === "true") {
tailscaleAuth = true;
}
if (process.env.npm_config_authenticated_private === "true") {
tailscaleAuth = true;
}
const env: NodeJS.ProcessEnv = {
...process.env,
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
};
if (mode === "dev") {
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
}
if (mode === "watch") {
env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
}
if (tailscaleAuth) {
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
env.HOST = "0.0.0.0";
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
} else {
console.log("[paperclip] dev mode: local_trusted (default)");
}
const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100;
const devService = createDevServiceIdentity({
mode,
forwardedArgs,
tailscaleAuth,
port: serverPort,
});
const existingRunner = await findAdoptableLocalService({
serviceKey: devService.serviceKey,
cwd: repoRoot,
envFingerprint: devService.envFingerprint,
port: serverPort,
});
if (existingRunner) {
console.log(
`[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`,
);
process.exit(0);
}
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
let previousSnapshot = collectWatchedSnapshot();
let dirtyPaths = new Set<string>();
let pendingMigrations: string[] = [];
let lastChangedAt: string | null = null;
let lastRestartAt: string | null = null;
let scanInFlight = false;
let restartInFlight = false;
let shuttingDown = false;
let childExitWasExpected = false;
let child: ReturnType<typeof spawn> | null = null;
let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null;
let scanTimer: ReturnType<typeof setInterval> | null = null;
let autoRestartTimer: ReturnType<typeof setInterval> | null = null;
function toError(error: unknown, context = "Dev runner command failed") {
if (error instanceof Error) return error;
if (error === undefined) return new Error(context);
if (typeof error === "string") return new Error(`${context}: ${error}`);
try {
return new Error(`${context}: ${JSON.stringify(error)}`);
} catch {
return new Error(`${context}: ${String(error)}`);
}
}
process.on("uncaughtException", async (error) => {
await removeLocalServiceRegistryRecord(devService.serviceKey);
const err = toError(error, "Uncaught exception in dev runner");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
});
process.on("unhandledRejection", async (reason) => {
await removeLocalServiceRegistryRecord(devService.serviceKey);
const err = toError(reason, "Unhandled promise rejection in dev runner");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
});
function formatPendingMigrationSummary(migrations: string[]) {
if (migrations.length === 0) return "none";
return migrations.length > 3
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
: migrations.join(", ");
}
function exitForSignal(signal: NodeJS.Signals) {
if (signal === "SIGINT") {
process.exit(130);
}
if (signal === "SIGTERM") {
process.exit(143);
}
process.exit(1);
}
function toRelativePath(absolutePath: string) {
return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
}
function readSignature(absolutePath: string) {
const stats = statSync(absolutePath);
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
}
function addFileToSnapshot(snapshot: Map<string, string>, absolutePath: string) {
const relativePath = toRelativePath(absolutePath);
if (ignoredRelativePaths.has(relativePath)) return;
if (!shouldTrackDevServerPath(relativePath)) return;
snapshot.set(relativePath, readSignature(absolutePath));
}
function walkDirectory(snapshot: Map<string, string>, absoluteDirectory: string) {
if (!existsSync(absoluteDirectory)) return;
for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
if (ignoredDirectoryNames.has(entry.name)) continue;
const absolutePath = path.join(absoluteDirectory, entry.name);
if (entry.isDirectory()) {
walkDirectory(snapshot, absolutePath);
continue;
}
if (entry.isFile() || entry.isSymbolicLink()) {
addFileToSnapshot(snapshot, absolutePath);
}
}
}
function collectWatchedSnapshot() {
const snapshot = new Map<string, string>();
for (const absoluteDirectory of watchedDirectories) {
walkDirectory(snapshot, absoluteDirectory);
}
for (const absoluteFile of watchedFiles) {
if (!existsSync(absoluteFile)) continue;
addFileToSnapshot(snapshot, absoluteFile);
}
return snapshot;
}
function diffSnapshots(previous: Map<string, string>, next: Map<string, string>) {
const changed = new Set<string>();
for (const [relativePath, signature] of next) {
if (previous.get(relativePath) !== signature) {
changed.add(relativePath);
}
}
for (const relativePath of previous.keys()) {
if (!next.has(relativePath)) {
changed.add(relativePath);
}
}
return [...changed].sort();
}
function ensureDevStatusDirectory() {
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
}
function writeDevServerStatus() {
if (mode !== "dev") return;
ensureDevStatusDirectory();
const changedPaths = [...dirtyPaths].sort();
writeFileSync(
devServerStatusFilePath,
`${JSON.stringify({
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
lastChangedAt,
changedPathCount: changedPaths.length,
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
pendingMigrations,
lastRestartAt,
}, null, 2)}\n`,
"utf8",
);
}
function clearDevServerStatus() {
if (mode !== "dev") return;
rmSync(devServerStatusFilePath, { force: true });
}
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
await writeLocalServiceRegistryRecord({
version: 1,
serviceKey: devService.serviceKey,
profileKind: "paperclip-dev",
serviceName: devService.serviceName,
command: "dev-runner.ts",
cwd: repoRoot,
envFingerprint: devService.envFingerprint,
port: serverPort,
url: `http://127.0.0.1:${serverPort}`,
pid: process.pid,
processGroupId: null,
provider: "local_process",
runtimeServiceId: null,
reuseKey: null,
startedAt: lastRestartAt ?? new Date().toISOString(),
lastSeenAt: new Date().toISOString(),
metadata: {
repoRoot,
mode,
childPid: child?.pid ?? null,
url: `http://127.0.0.1:${serverPort}`,
...extra,
},
});
}
async function runPnpm(args: string[], options: {
stdio?: "inherit" | ["ignore", "pipe", "pipe"];
env?: NodeJS.ProcessEnv;
cwd?: string;
} = {}) {
return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => {
const spawned = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env,
cwd: options.cwd,
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
if (spawned.stdout) {
spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
});
}
if (spawned.stderr) {
spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
});
}
spawned.on("error", reject);
spawned.on("exit", (code, signal) => {
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
});
});
});
}
async function getMigrationStatusPayload() {
const status = await runPnpm(
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
{ env },
);
if (status.code !== 0) {
process.stderr.write(
status.stderr ||
status.stdout ||
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
);
process.exit(status.code);
}
try {
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
} catch (error) {
process.stderr.write(
status.stderr ||
status.stdout ||
"[paperclip] migration-status returned invalid JSON payload\n",
);
throw toError(error, "Unable to parse migration-status JSON output");
}
}
async function refreshPendingMigrations() {
const payload = await getMigrationStatusPayload();
pendingMigrations =
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
: [];
writeDevServerStatus();
return payload;
}
async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) {
const interactive = options.interactive ?? mode === "watch";
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
const payload = await refreshPendingMigrations();
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
return;
}
let shouldApply = autoApply;
if (!autoApply && interactive) {
if (!stdin.isTTY || !stdout.isTTY) {
shouldApply = true;
} else {
const prompt = createInterface({ input: stdin, output: stdout });
try {
const answer = (
await prompt.question(
`Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
)
)
.trim()
.toLowerCase();
shouldApply = answer === "y" || answer === "yes";
} finally {
prompt.close();
}
}
}
if (!shouldApply) {
if (exitOnDecline) {
process.stderr.write(
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`,
);
process.exit(1);
}
return;
}
const exit = await runPnpm(["db:migrate"], {
stdio: "inherit",
env,
cwd: repoRoot,
});
if (exit.signal) {
exitForSignal(exit.signal);
return;
}
if (exit.code !== 0) {
process.exit(exit.code);
}
await refreshPendingMigrations();
}
async function buildPluginSdk() {
console.log("[paperclip] building plugin sdk...");
const result = await runPnpm(
["--filter", "@paperclipai/plugin-sdk", "build"],
{ stdio: "inherit" },
);
if (result.signal) {
exitForSignal(result.signal);
return;
}
if (result.code !== 0) {
console.error("[paperclip] plugin sdk build failed");
process.exit(result.code);
}
}
async function markChildAsCurrent() {
previousSnapshot = collectWatchedSnapshot();
dirtyPaths = new Set();
lastChangedAt = null;
lastRestartAt = new Date().toISOString();
await refreshPendingMigrations();
await updateDevServiceRecord();
}
async function scanForBackendChanges() {
if (mode !== "dev" || scanInFlight || restartInFlight) return;
scanInFlight = true;
try {
const nextSnapshot = collectWatchedSnapshot();
const changed = diffSnapshots(previousSnapshot, nextSnapshot);
previousSnapshot = nextSnapshot;
if (changed.length === 0) return;
for (const relativePath of changed) {
dirtyPaths.add(relativePath);
}
lastChangedAt = new Date().toISOString();
await refreshPendingMigrations();
} finally {
scanInFlight = false;
}
}
async function getDevHealthPayload() {
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`);
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await response.json();
}
async function waitForChildExit() {
if (!childExitPromise) {
return { code: 0, signal: null };
}
return await childExitPromise;
}
async function stopChildForRestart() {
if (!child) return { code: 0, signal: null };
childExitWasExpected = true;
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
if (child) {
child.kill("SIGKILL");
}
}, gracefulShutdownTimeoutMs);
try {
return await waitForChildExit();
} finally {
clearTimeout(killTimer);
}
}
async function startServerChild() {
await buildPluginSdk();
const serverScript = mode === "watch" ? "dev:watch" : "dev";
child = spawn(
pnpmBin,
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
{ stdio: "inherit", env, shell: process.platform === "win32" },
);
childExitPromise = new Promise((resolve, reject) => {
child?.on("error", reject);
child?.on("exit", (code, signal) => {
const expected = childExitWasExpected;
childExitWasExpected = false;
child = null;
childExitPromise = null;
void touchLocalServiceRegistryRecord(devService.serviceKey, {
metadata: {
repoRoot,
mode,
childPid: null,
url: `http://127.0.0.1:${serverPort}`,
},
});
resolve({ code: code ?? 0, signal });
if (restartInFlight || expected || shuttingDown) {
return;
}
if (signal) {
exitForSignal(signal);
return;
}
process.exit(code ?? 0);
});
});
await markChildAsCurrent();
}
async function maybeAutoRestartChild() {
if (mode !== "dev" || restartInFlight || !child) return;
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
restartInFlight = true;
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
try {
health = await getDevHealthPayload();
} catch {
restartInFlight = false;
return;
}
const devServer = health?.devServer;
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
restartInFlight = false;
return;
}
if ((devServer.activeRunCount ?? 0) > 0) {
restartInFlight = false;
return;
}
try {
await maybePreflightMigrations({
autoApply: true,
interactive: false,
exitOnDecline: false,
});
await stopChildForRestart();
await startServerChild();
} catch (error) {
const err = toError(error, "Auto-restart failed");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
} finally {
restartInFlight = false;
}
}
function installDevIntervals() {
if (mode !== "dev") return;
scanTimer = setInterval(() => {
void scanForBackendChanges();
}, scanIntervalMs);
autoRestartTimer = setInterval(() => {
void maybeAutoRestartChild();
}, autoRestartPollIntervalMs);
}
function clearDevIntervals() {
if (scanTimer) {
clearInterval(scanTimer);
scanTimer = null;
}
if (autoRestartTimer) {
clearInterval(autoRestartTimer);
autoRestartTimer = null;
}
}
async function shutdown(signal: NodeJS.Signals) {
if (shuttingDown) return;
shuttingDown = true;
clearDevIntervals();
clearDevServerStatus();
await removeLocalServiceRegistryRecord(devService.serviceKey);
if (!child) {
exitForSignal(signal);
return;
}
childExitWasExpected = true;
child.kill(signal);
const exit = await waitForChildExit();
if (exit.signal) {
exitForSignal(exit.signal);
return;
}
process.exit(exit.code ?? 0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
await maybePreflightMigrations();
await startServerChild();
installDevIntervals();
if (mode === "watch") {
const exit = await waitForChildExit();
await removeLocalServiceRegistryRecord(devService.serviceKey);
if (exit.signal) {
exitForSignal(exit.signal);
}
process.exit(exit.code ?? 0);
}

View file

@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createLocalServiceKey } from "../server/src/services/local-service-supervisor.ts";
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export function createDevServiceIdentity(input: {
mode: "watch" | "dev";
forwardedArgs: string[];
tailscaleAuth: boolean;
port: number;
}) {
const envFingerprint = createHash("sha256")
.update(
JSON.stringify({
mode: input.mode,
forwardedArgs: input.forwardedArgs,
tailscaleAuth: input.tailscaleAuth,
port: input.port,
}),
)
.digest("hex");
const serviceName = input.mode === "watch" ? "paperclip-dev-watch" : "paperclip-dev-once";
const serviceKey = createLocalServiceKey({
profileKind: "paperclip-dev",
serviceName,
cwd: repoRoot,
command: "dev-runner.ts",
envFingerprint,
port: input.port,
scope: {
repoRoot,
mode: input.mode,
},
});
return {
serviceKey,
serviceName,
envFingerprint,
};
}

44
scripts/dev-service.ts Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env -S node --import tsx
import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts";
import { repoRoot } from "./dev-service-profile.ts";
function toDisplayLines(records: Awaited<ReturnType<typeof listLocalServiceRegistryRecords>>) {
return records.map((record) => {
const childPid = typeof record.metadata?.childPid === "number" ? ` child=${record.metadata.childPid}` : "";
const url = typeof record.metadata?.url === "string" ? ` url=${record.metadata.url}` : "";
return `${record.serviceName} pid=${record.pid}${childPid} cwd=${record.cwd}${url}`;
});
}
const command = process.argv[2] ?? "list";
const records = await listLocalServiceRegistryRecords({
profileKind: "paperclip-dev",
metadata: { repoRoot },
});
if (command === "list") {
if (records.length === 0) {
console.log("No Paperclip dev services registered for this repo.");
process.exit(0);
}
for (const line of toDisplayLines(records)) {
console.log(line);
}
process.exit(0);
}
if (command === "stop") {
if (records.length === 0) {
console.log("No Paperclip dev services registered for this repo.");
process.exit(0);
}
for (const record of records) {
await terminateLocalService(record);
await removeLocalServiceRegistryRecord(record.serviceKey);
console.log(`Stopped ${record.serviceName} (pid ${record.pid})`);
}
process.exit(0);
}
console.error(`Unknown dev-service command: ${command}`);
process.exit(1);

View file

@ -0,0 +1,124 @@
#!/usr/bin/env -S node --import tsx
import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import path from "node:path";
import { repoRoot } from "./dev-service-profile.ts";
type WorkspaceLinkMismatch = {
packageName: string;
expectedPath: string;
actualPath: string | null;
};
function readJsonFile(filePath: string): Record<string, unknown> {
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
}
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
const packagePaths = new Map<string, string>();
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
function visit(dirPath: string) {
const packageJsonPath = path.join(dirPath, "package.json");
if (existsSync(packageJsonPath)) {
const packageJson = readJsonFile(packageJsonPath);
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
packagePaths.set(packageJson.name, dirPath);
}
}
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (ignoredDirNames.has(entry.name)) continue;
visit(path.join(dirPath, entry.name));
}
}
visit(path.join(rootDir, "packages"));
visit(path.join(rootDir, "server"));
visit(path.join(rootDir, "ui"));
visit(path.join(rootDir, "cli"));
return packagePaths;
}
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json"));
const dependencies = {
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
};
const mismatches: WorkspaceLinkMismatch[] = [];
for (const [packageName, version] of Object.entries(dependencies)) {
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
const expectedPath = workspacePackagePaths.get(packageName);
if (!expectedPath) continue;
const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/"));
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
if (actualPath === path.resolve(expectedPath)) continue;
mismatches.push({
packageName,
expectedPath: path.resolve(expectedPath),
actualPath,
});
}
return mismatches;
}
function runCommand(command: string, args: string[], cwd: string) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`,
),
);
});
});
}
async function ensureServerWorkspaceLinksCurrent() {
const mismatches = findServerWorkspaceLinkMismatches();
if (mismatches.length === 0) return;
console.log("[paperclip] detected stale workspace package links for server; relinking dependencies...");
for (const mismatch of mismatches) {
console.log(
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
);
}
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
await runCommand(
pnpmBin,
["install", "--force", "--config.confirmModulesPurge=false"],
repoRoot,
);
const remainingMismatches = findServerWorkspaceLinkMismatches();
if (remainingMismatches.length === 0) return;
throw new Error(
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
);
}
await ensureServerWorkspaceLinksCurrent();

6
scripts/install-hooks.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/sh
# Install Nexus git hooks
REPO_ROOT="$(git rev-parse --show-toplevel)"
cp "$REPO_ROOT/scripts/nexus-commit-msg-hook.sh" "$REPO_ROOT/.git/hooks/commit-msg"
chmod +x "$REPO_ROOT/.git/hooks/commit-msg"
echo "Nexus commit-msg hook installed."

View file

@ -8,64 +8,199 @@
#
set -euo pipefail
shopt -s nullglob
DRY_RUN=false
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
DRY_RUN=true
fi
# Collect PIDs of node processes running from any paperclip directory.
# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/...
# Excludes postgres-related processes.
pids=()
lines=()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_PARENT="$(dirname "$REPO_ROOT")"
node_pids=()
node_lines=()
pg_pids=()
pg_pidfiles=()
pg_data_dirs=()
is_pid_running() {
local pid="$1"
kill -0 "$pid" 2>/dev/null
}
read_pidfile_pid() {
local pidfile="$1"
local first_line
first_line="$(head -n 1 "$pidfile" 2>/dev/null | tr -d '[:space:]' || true)"
if [[ "$first_line" =~ ^[0-9]+$ ]] && (( first_line > 0 )); then
printf '%s\n' "$first_line"
return 0
fi
return 1
}
command_for_pid() {
local pid="$1"
ps -o command= -p "$pid" 2>/dev/null || true
}
append_postgres_from_pidfile() {
local pidfile="$1"
local pid cmd data_dir
pid="$(read_pidfile_pid "$pidfile" || true)"
[[ -n "$pid" ]] || return 0
is_pid_running "$pid" || return 0
cmd="$(command_for_pid "$pid")"
[[ "$cmd" == *postgres* ]] || return 0
for existing_pid in "${pg_pids[@]:-}"; do
[[ "$existing_pid" == "$pid" ]] && return 0
done
data_dir="$(dirname "$pidfile")"
pg_pids+=("$pid")
pg_pidfiles+=("$pidfile")
pg_data_dirs+=("$data_dir")
}
wait_for_pid_exit() {
local pid="$1"
local timeout_sec="$2"
local waited=0
while is_pid_running "$pid"; do
if (( waited >= timeout_sec * 10 )); then
return 1
fi
sleep 0.1
((waited += 1))
done
return 0
}
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# skip postgres processes
[[ "$line" == *postgres* ]] && continue
pid=$(echo "$line" | awk '{print $2}')
pids+=("$pid")
lines+=("$line")
node_pids+=("$pid")
node_lines+=("$line")
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
if [[ ${#pids[@]} -eq 0 ]]; then
candidate_pidfiles=()
candidate_pidfiles+=(
"$HOME"/.paperclip/instances/*/db/postmaster.pid
"$REPO_ROOT"/.paperclip/instances/*/db/postmaster.pid
"$REPO_ROOT"/.paperclip/runtime-services/instances/*/db/postmaster.pid
)
for sibling_root in "$REPO_PARENT"/paperclip*; do
[[ -d "$sibling_root" ]] || continue
candidate_pidfiles+=(
"$sibling_root"/.paperclip/instances/*/db/postmaster.pid
"$sibling_root"/.paperclip/runtime-services/instances/*/db/postmaster.pid
)
done
for pidfile in "${candidate_pidfiles[@]:-}"; do
[[ -f "$pidfile" ]] || continue
append_postgres_from_pidfile "$pidfile"
done
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then
echo "No Paperclip dev processes found."
exit 0
fi
echo "Found ${#pids[@]} Paperclip dev process(es):"
echo ""
if [[ ${#node_pids[@]} -gt 0 ]]; then
echo "Found ${#node_pids[@]} Paperclip dev node process(es):"
echo ""
for i in "${!pids[@]}"; do
line="${lines[$i]}"
pid=$(echo "$line" | awk '{print $2}')
start=$(echo "$line" | awk '{print $9}')
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
# Shorten the command for readability
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
done
for i in "${!node_pids[@]:-}"; do
line="${node_lines[$i]}"
pid=$(echo "$line" | awk '{print $2}')
start=$(echo "$line" | awk '{print $9}')
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
done
echo ""
echo ""
fi
if [[ ${#pg_pids[@]} -gt 0 ]]; then
echo "Found ${#pg_pids[@]} embedded PostgreSQL master process(es):"
echo ""
for i in "${!pg_pids[@]:-}"; do
pid="${pg_pids[$i]}"
data_dir="${pg_data_dirs[$i]}"
pidfile="${pg_pidfiles[$i]}"
short_data_dir="${data_dir/#$HOME\//}"
short_pidfile="${pidfile/#$HOME\//}"
printf " PID %-7s data %-55s pidfile %s\n" "$pid" "$short_data_dir" "$short_pidfile"
done
echo ""
fi
if [[ "$DRY_RUN" == true ]]; then
echo "Dry run — re-run without --dry to kill these processes."
exit 0
fi
echo "Sending SIGTERM..."
for pid in "${pids[@]}"; do
kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone"
done
if [[ ${#node_pids[@]} -gt 0 ]]; then
echo "Sending SIGTERM to Paperclip node processes..."
for pid in "${node_pids[@]}"; do
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
done
echo "Waiting briefly for node processes to exit..."
sleep 2
fi
# Give processes a moment to exit, then SIGKILL any stragglers
sleep 2
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo " $pid still alive, sending SIGKILL..."
kill -9 "$pid" 2>/dev/null || true
leftover_pg_pids=()
leftover_pg_data_dirs=()
for i in "${!pg_pids[@]:-}"; do
pid="${pg_pids[$i]}"
if is_pid_running "$pid"; then
leftover_pg_pids+=("$pid")
leftover_pg_data_dirs+=("${pg_data_dirs[$i]}")
fi
done
if [[ ${#leftover_pg_pids[@]} -gt 0 ]]; then
echo "Sending SIGTERM to leftover embedded PostgreSQL processes..."
for i in "${!leftover_pg_pids[@]:-}"; do
pid="${leftover_pg_pids[$i]}"
data_dir="${leftover_pg_data_dirs[$i]}"
kill -TERM "$pid" 2>/dev/null \
&& echo " signaled $pid ($data_dir)" \
|| echo " $pid already gone"
done
echo "Waiting up to 15s for PostgreSQL to shut down cleanly..."
for pid in "${leftover_pg_pids[@]:-}"; do
if wait_for_pid_exit "$pid" 15; then
echo " postgres $pid exited cleanly"
fi
done
fi
if [[ ${#node_pids[@]} -gt 0 ]]; then
for pid in "${node_pids[@]:-}"; do
if kill -0 "$pid" 2>/dev/null; then
echo " node $pid still alive, sending SIGKILL..."
kill -KILL "$pid" 2>/dev/null || true
fi
done
fi
if [[ ${#pg_pids[@]} -gt 0 ]]; then
for pid in "${pg_pids[@]:-}"; do
if kill -0 "$pid" 2>/dev/null; then
echo " postgres $pid still alive, sending SIGKILL..."
kill -KILL "$pid" 2>/dev/null || true
fi
done
fi
echo "Done."

View file

@ -0,0 +1,23 @@
#!/bin/sh
# Nexus fork: enforce [nexus] prefix on all fork commits
# Allows upstream merge commits and rebase-generated commits through
MSG_FILE="$1"
FIRST_LINE=$(head -1 "$MSG_FILE")
# Skip merge commits (git generates these automatically during rebase/merge)
if echo "$FIRST_LINE" | grep -qE "^Merge (branch|pull request|remote-tracking)"; then
exit 0
fi
# Skip fixup/squash commits (used during interactive rebase)
if echo "$FIRST_LINE" | grep -qE "^(fixup|squash)!"; then
exit 0
fi
# Enforce [nexus] prefix
if ! echo "$FIRST_LINE" | grep -qE "^\[nexus\]"; then
echo "ERROR: Commit message must start with [nexus]"
echo " Got: $FIRST_LINE"
echo " Example: [nexus] feat: add branding package"
exit 1
fi

View file

@ -0,0 +1,883 @@
#!/usr/bin/env npx tsx
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_QUERY = "\"Co-Authored-By: Paperclip <noreply@paperclip.ing>\"";
const DEFAULT_CACHE_FILE = path.resolve("data/paperclip-commit-metrics-cache.json");
const DEFAULT_SEARCH_START = "2008-01-01T00:00:00Z";
const SEARCH_WINDOW_LIMIT = 900;
const MIN_WINDOW_MS = 60_000;
const DEFAULT_STATS_FETCH_LIMIT = 250;
const DEFAULT_STATS_CONCURRENCY = 4;
const DEFAULT_SEARCH_FIELD = "committer-date";
const PAPERCLIP_EMAIL = "noreply@paperclip.ing";
const PAPERCLIP_NAME = "paperclip";
interface CliOptions {
cacheFile: string;
end: Date;
excludeOwners: string[];
exportFormat: "csv" | "json";
includePrivate: boolean;
json: boolean;
output: string | null;
query: string;
refreshSearch: boolean;
refreshStats: boolean;
searchField: "author-date" | "committer-date";
start: Date;
statsConcurrency: number;
statsFetchLimit: number;
skipStats: boolean;
}
interface SearchCommitItem {
author: {
login?: string;
} | null;
commit: {
author: {
date: string;
email: string | null;
name: string | null;
} | null;
message: string;
};
html_url: string;
repository: {
full_name: string;
html_url: string;
};
sha: string;
}
interface CommitStats {
additions: number;
deletions: number;
total: number;
}
interface CachedCommit {
authorEmail: string | null;
authorLogin: string | null;
authorName: string | null;
committedAt: string | null;
contributors: ContributorRecord[];
htmlUrl: string;
repositoryFullName: string;
repositoryUrl: string;
sha: string;
}
interface CachedCommitStats extends CommitStats {
fetchedAt: string;
}
interface ContributorRecord {
displayName: string;
email: string | null;
key: string;
login: string | null;
}
interface WindowCacheEntry {
completedAt: string;
key: string;
shas: string[];
totalCount: number;
}
interface CacheFile {
commits: Record<string, CachedCommit>;
queryKey: string;
searchField: CliOptions["searchField"];
stats: Record<string, CachedCommitStats>;
updatedAt: string | null;
version: number;
windows: Record<string, WindowCacheEntry>;
}
interface SearchResponse {
incomplete_results: boolean;
items: SearchCommitItem[];
total_count: number;
}
interface SearchWindowResult {
shas: Set<string>;
totalCount: number;
}
interface Summary {
cacheFile: string;
contributors: {
count: number;
sample: ContributorRecord[];
};
detectedQuery: string;
lineStats: {
additions: number;
complete: boolean;
coveredCommits: number;
deletions: number;
missingCommits: number;
totalChanges: number;
};
range: {
end: string;
searchField: CliOptions["searchField"];
start: string;
};
filters: {
excludedOwners: string[];
};
repos: {
count: number;
sample: string[];
};
statsFetch: {
fetchedThisRun: number;
skipped: boolean;
};
totals: {
commits: number;
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const cache = await loadCache(options.cacheFile, options);
const client = new GitHubClient(await resolveGitHubToken());
const { shas } = await searchWindow(client, cache, options, options.start, options.end);
const sortedShas = [...shas].sort();
let fetchedThisRun = 0;
if (!options.skipStats) {
fetchedThisRun = await enrichCommitStats(client, cache, options, sortedShas);
}
cache.updatedAt = new Date().toISOString();
await saveCache(options.cacheFile, cache);
const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options));
const summary = buildSummary(cache, options, filteredShas, fetchedThisRun);
if (options.output) {
await writeExport(options.output, options.exportFormat, cache, filteredShas, summary);
}
if (options.json) {
console.log(JSON.stringify(summary, null, 2));
return;
}
printSummary(summary);
}
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
cacheFile: DEFAULT_CACHE_FILE,
end: new Date(),
excludeOwners: [],
exportFormat: "csv",
includePrivate: false,
json: false,
output: null,
query: DEFAULT_QUERY,
refreshSearch: false,
refreshStats: false,
searchField: DEFAULT_SEARCH_FIELD,
start: new Date(DEFAULT_SEARCH_START),
statsConcurrency: DEFAULT_STATS_CONCURRENCY,
statsFetchLimit: DEFAULT_STATS_FETCH_LIMIT,
skipStats: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--cache-file":
options.cacheFile = requireValue(argv, ++index, arg);
break;
case "--end":
options.end = parseDateArg(requireValue(argv, ++index, arg), arg);
break;
case "--exclude-owner":
options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase());
break;
case "--export-format": {
const value = requireValue(argv, ++index, arg);
if (value !== "csv" && value !== "json") {
throw new Error(`Invalid --export-format value: ${value}`);
}
options.exportFormat = value;
break;
}
case "--include-private":
options.includePrivate = true;
break;
case "--json":
options.json = true;
break;
case "--output":
options.output = requireValue(argv, ++index, arg);
break;
case "--query":
options.query = requireValue(argv, ++index, arg);
break;
case "--refresh-search":
options.refreshSearch = true;
break;
case "--refresh-stats":
options.refreshStats = true;
break;
case "--search-field": {
const value = requireValue(argv, ++index, arg);
if (value !== "author-date" && value !== "committer-date") {
throw new Error(`Invalid --search-field value: ${value}`);
}
options.searchField = value;
break;
}
case "--skip-stats":
options.skipStats = true;
break;
case "--start":
options.start = parseDateArg(requireValue(argv, ++index, arg), arg);
break;
case "--stats-concurrency":
options.statsConcurrency = parsePositiveInt(requireValue(argv, ++index, arg), arg);
break;
case "--stats-fetch-limit":
options.statsFetchLimit = parseNonNegativeInt(requireValue(argv, ++index, arg), arg);
break;
case "--help":
printHelp();
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (Number.isNaN(options.start.getTime()) || Number.isNaN(options.end.getTime())) {
throw new Error("Invalid start or end date");
}
if (options.start >= options.end) {
throw new Error("--start must be earlier than --end");
}
return options;
}
function requireValue(argv: string[], index: number, flag: string): string {
const value = argv[index];
if (!value) {
throw new Error(`Missing value for ${flag}`);
}
return value;
}
function parseDateArg(value: string, flag: string): Date {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid date for ${flag}: ${value}`);
}
return parsed;
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid positive integer for ${flag}: ${value}`);
}
return parsed;
}
function parseNonNegativeInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Invalid non-negative integer for ${flag}: ${value}`);
}
return parsed;
}
function printHelp() {
console.log(`Usage: tsx scripts/paperclip-commit-metrics.ts [options]
Options:
--start <date> ISO date/time lower bound (default: ${DEFAULT_SEARCH_START})
--end <date> ISO date/time upper bound (default: now)
--query <search> Commit search string (default: ${DEFAULT_QUERY})
--search-field <field> author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD})
--include-private Include repos visible to the current token
--exclude-owner <owner> Exclude repositories owned by this GitHub owner/org (repeatable)
--cache-file <path> Cache path (default: ${DEFAULT_CACHE_FILE})
--skip-stats Skip additions/deletions enrichment
--stats-fetch-limit <n> Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT})
--stats-concurrency <n> Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY})
--output <path> Write the full filtered result set to a file
--export-format <format> csv | json for --output exports (default: csv)
--refresh-search Ignore cached search windows
--refresh-stats Re-fetch cached commit stats
--json Print JSON summary
--help Show this help
`);
}
async function resolveGitHubToken(): Promise<string> {
const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
if (envToken) {
return envToken;
}
const { stdout } = await execFileAsync("gh", ["auth", "token"]);
const token = stdout.trim();
if (!token) {
throw new Error("Unable to resolve a GitHub token. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`.");
}
return token;
}
async function loadCache(cacheFile: string, options: CliOptions): Promise<CacheFile> {
try {
const raw = await fs.readFile(cacheFile, "utf8");
const parsed = JSON.parse(raw) as CacheFile;
if (parsed.version !== 1 || parsed.queryKey !== buildQueryKey(options) || parsed.searchField !== options.searchField) {
return createEmptyCache(options);
}
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return createEmptyCache(options);
}
throw error;
}
}
function createEmptyCache(options: CliOptions): CacheFile {
return {
commits: {},
queryKey: buildQueryKey(options),
searchField: options.searchField,
stats: {},
updatedAt: null,
version: 1,
windows: {},
};
}
function buildQueryKey(options: CliOptions): string {
const visibility = options.includePrivate ? "all" : "public";
return JSON.stringify({
query: options.query,
searchField: options.searchField,
visibility,
});
}
async function saveCache(cacheFile: string, cache: CacheFile): Promise<void> {
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8");
}
async function searchWindow(
client: GitHubClient,
cache: CacheFile,
options: CliOptions,
start: Date,
end: Date,
): Promise<SearchWindowResult> {
const windowKey = makeWindowKey(start, end);
if (!options.refreshSearch) {
const cached = cache.windows[windowKey];
if (cached) {
return { shas: new Set(cached.shas), totalCount: cached.totalCount };
}
}
const firstPage = await searchPage(client, options, start, end, 1, 100);
if (firstPage.incomplete_results) {
throw new Error(`GitHub returned incomplete search results for window ${windowKey}`);
}
if (firstPage.total_count > SEARCH_WINDOW_LIMIT) {
const durationMs = end.getTime() - start.getTime();
if (durationMs <= MIN_WINDOW_MS) {
throw new Error(
`Search window ${windowKey} still has ${firstPage.total_count} results after splitting to ${durationMs}ms.`,
);
}
const midpoint = new Date(start.getTime() + Math.floor(durationMs / 2));
const left = await searchWindow(client, cache, options, start, midpoint);
const right = await searchWindow(client, cache, options, new Date(midpoint.getTime() + 1), end);
const shas = new Set([...left.shas, ...right.shas]);
cache.windows[windowKey] = {
completedAt: new Date().toISOString(),
key: windowKey,
shas: [...shas],
totalCount: shas.size,
};
return { shas, totalCount: shas.size };
}
const pageCount = Math.ceil(firstPage.total_count / 100);
const shas = new Set<string>();
ingestSearchItems(cache, firstPage.items, shas);
for (let page = 2; page <= pageCount; page += 1) {
const response = await searchPage(client, options, start, end, page, 100);
if (response.incomplete_results) {
throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`);
}
ingestSearchItems(cache, response.items, shas);
}
cache.windows[windowKey] = {
completedAt: new Date().toISOString(),
key: windowKey,
shas: [...shas],
totalCount: firstPage.total_count,
};
return { shas, totalCount: firstPage.total_count };
}
async function searchPage(
client: GitHubClient,
options: CliOptions,
start: Date,
end: Date,
page: number,
perPage: number,
): Promise<SearchResponse> {
const searchQuery = buildSearchQuery(options, start, end);
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
q: searchQuery,
});
return client.getJson<SearchResponse>(`/search/commits?${params.toString()}`);
}
function buildSearchQuery(options: CliOptions, start: Date, end: Date): string {
const qualifiers = [`${options.searchField}:${formatQueryDate(start)}..${formatQueryDate(end)}`];
if (!options.includePrivate) {
qualifiers.push("is:public");
}
return `${options.query} ${qualifiers.join(" ")}`.trim();
}
function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] {
if (options.excludeOwners.length === 0) {
return shas;
}
const excludedOwners = new Set(options.excludeOwners);
return shas.filter((sha) => {
const commit = cache.commits[sha];
if (!commit) {
return false;
}
return !excludedOwners.has(getRepoOwner(commit.repositoryFullName));
});
}
function sortFilteredShas(cache: CacheFile, shas: string[]): string[] {
return [...shas].sort((leftSha, rightSha) => {
const left = cache.commits[leftSha];
const right = cache.commits[rightSha];
const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0;
const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? "");
if (repoCompare !== 0) {
return repoCompare;
}
return leftSha.localeCompare(rightSha);
});
}
function formatQueryDate(value: Date): string {
return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
}
function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set<string>) {
for (const item of items) {
shas.add(item.sha);
cache.commits[item.sha] = {
authorEmail: item.commit.author?.email ?? null,
authorLogin: item.author?.login ?? null,
authorName: item.commit.author?.name ?? null,
committedAt: item.commit.author?.date ?? null,
contributors: extractContributors(item),
htmlUrl: item.html_url,
repositoryFullName: item.repository.full_name,
repositoryUrl: item.repository.html_url,
sha: item.sha,
};
}
}
function extractContributors(item: SearchCommitItem): ContributorRecord[] {
const contributors = new Map<string, ContributorRecord>();
const primaryAuthor = normalizeContributor({
email: item.commit.author?.email ?? null,
login: item.author?.login ?? null,
name: item.commit.author?.name ?? null,
});
if (primaryAuthor) {
contributors.set(primaryAuthor.key, primaryAuthor);
}
const coAuthorPattern = /^co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim;
for (const match of item.commit.message.matchAll(coAuthorPattern)) {
const contributor = normalizeContributor({
email: match[2] ?? null,
login: null,
name: match[1] ?? null,
});
if (contributor) {
contributors.set(contributor.key, contributor);
}
}
return [...contributors.values()];
}
function normalizeContributor(input: {
email: string | null;
login: string | null;
name: string | null;
}): ContributorRecord | null {
const email = normalizeOptional(input.email);
const login = normalizeOptional(input.login);
const displayName = normalizeOptional(input.name) ?? login ?? email;
if (!displayName && !email && !login) {
return null;
}
if ((email && email === PAPERCLIP_EMAIL) || (displayName && displayName.toLowerCase() === PAPERCLIP_NAME)) {
return null;
}
const key = login ? `login:${login}` : email ? `email:${email}` : `name:${displayName!.toLowerCase()}`;
return {
displayName: displayName ?? email ?? login ?? "unknown",
email,
key,
login,
};
}
function normalizeOptional(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function getRepoOwner(repositoryFullName: string): string {
return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? "";
}
async function enrichCommitStats(
client: GitHubClient,
cache: CacheFile,
options: CliOptions,
shas: string[],
): Promise<number> {
const pending = shas.filter((sha) => options.refreshStats || !cache.stats[sha]).slice(0, options.statsFetchLimit);
let nextIndex = 0;
let fetched = 0;
const workers = Array.from({ length: Math.min(options.statsConcurrency, pending.length) }, async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
const sha = pending[currentIndex];
if (!sha) {
return;
}
const commit = cache.commits[sha];
if (!commit) {
continue;
}
const stats = await fetchCommitStats(client, commit.repositoryFullName, sha);
cache.stats[sha] = {
...stats,
fetchedAt: new Date().toISOString(),
};
fetched += 1;
}
});
await Promise.all(workers);
return fetched;
}
async function fetchCommitStats(client: GitHubClient, repositoryFullName: string, sha: string): Promise<CommitStats> {
const response = await client.getJson<{ stats?: CommitStats }>(
`/repos/${repositoryFullName}/commits/${sha}`,
);
return {
additions: response.stats?.additions ?? 0,
deletions: response.stats?.deletions ?? 0,
total: response.stats?.total ?? 0,
};
}
function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fetchedThisRun: number): Summary {
const repoNames = new Set<string>();
const contributors = new Map<string, ContributorRecord>();
let additions = 0;
let deletions = 0;
let coveredCommits = 0;
for (const sha of shas) {
const commit = cache.commits[sha];
if (!commit) {
continue;
}
repoNames.add(commit.repositoryFullName);
for (const contributor of commit.contributors) {
contributors.set(contributor.key, contributor);
}
const stats = cache.stats[sha];
if (stats) {
additions += stats.additions;
deletions += stats.deletions;
coveredCommits += 1;
}
}
const contributorSample = [...contributors.values()]
.sort((left, right) => left.displayName.localeCompare(right.displayName))
.slice(0, 10);
const repoSample = [...repoNames].sort((left, right) => left.localeCompare(right)).slice(0, 10);
return {
cacheFile: options.cacheFile,
contributors: {
count: contributors.size,
sample: contributorSample,
},
detectedQuery: buildSearchQuery(options, options.start, options.end),
lineStats: {
additions,
complete: coveredCommits === shas.length,
coveredCommits,
deletions,
missingCommits: shas.length - coveredCommits,
totalChanges: additions + deletions,
},
range: {
end: options.end.toISOString(),
searchField: options.searchField,
start: options.start.toISOString(),
},
filters: {
excludedOwners: [...options.excludeOwners].sort(),
},
repos: {
count: repoNames.size,
sample: repoSample,
},
statsFetch: {
fetchedThisRun,
skipped: options.skipStats,
},
totals: {
commits: shas.length,
},
};
}
function printSummary(summary: Summary) {
console.log("Paperclip commit metrics");
console.log(`Query: ${summary.detectedQuery}`);
console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`);
if (summary.filters.excludedOwners.length > 0) {
console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`);
}
console.log(`Commits: ${summary.totals.commits}`);
console.log(`Distinct repos: ${summary.repos.count}`);
console.log(`Distinct contributors: ${summary.contributors.count}`);
console.log(
`Line stats: +${summary.lineStats.additions} / -${summary.lineStats.deletions} / ${summary.lineStats.totalChanges} total`,
);
console.log(
`Line stat coverage: ${summary.lineStats.coveredCommits}/${summary.totals.commits}` +
(summary.lineStats.complete ? " (complete)" : " (partial; rerun to hydrate more commits)"),
);
console.log(`Stats fetched this run: ${summary.statsFetch.fetchedThisRun}${summary.statsFetch.skipped ? " (skipped)" : ""}`);
console.log(`Cache: ${summary.cacheFile}`);
if (summary.repos.sample.length > 0) {
console.log(`Sample repos: ${summary.repos.sample.join(", ")}`);
}
if (summary.contributors.sample.length > 0) {
console.log(
`Sample contributors: ${summary.contributors.sample
.map((contributor) => contributor.login ?? contributor.displayName)
.join(", ")}`,
);
}
}
async function writeExport(
outputPath: string,
format: CliOptions["exportFormat"],
cache: CacheFile,
shas: string[],
summary: Summary,
): Promise<void> {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
if (format === "json") {
const report = {
summary,
commits: shas.map((sha) => buildExportRow(cache, sha)),
};
await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
return;
}
const header = [
"committedAt",
"repository",
"repositoryUrl",
"sha",
"commitUrl",
"authorLogin",
"authorName",
"authorEmail",
"contributors",
"additions",
"deletions",
"totalChanges",
];
const rows = [header.join(",")];
for (const sha of shas) {
const row = buildExportRow(cache, sha);
rows.push(
[
row.committedAt,
row.repository,
row.repositoryUrl,
row.sha,
row.commitUrl,
row.authorLogin,
row.authorName,
row.authorEmail,
row.contributors,
String(row.additions),
String(row.deletions),
String(row.totalChanges),
]
.map(escapeCsv)
.join(","),
);
}
await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8");
}
function buildExportRow(cache: CacheFile, sha: string) {
const commit = cache.commits[sha];
if (!commit) {
throw new Error(`Missing cached commit for sha ${sha}`);
}
const stats = cache.stats[sha];
return {
additions: stats?.additions ?? 0,
authorEmail: commit.authorEmail ?? "",
authorLogin: commit.authorLogin ?? "",
authorName: commit.authorName ?? "",
commitUrl: commit.htmlUrl,
committedAt: commit.committedAt ?? "",
contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "),
deletions: stats?.deletions ?? 0,
repository: commit.repositoryFullName,
repositoryUrl: commit.repositoryUrl,
sha: commit.sha,
totalChanges: stats?.total ?? 0,
};
}
function escapeCsv(value: string): string {
if (value.includes(",") || value.includes("\"") || value.includes("\n")) {
return `"${value.replaceAll("\"", "\"\"")}"`;
}
return value;
}
function makeWindowKey(start: Date, end: Date): string {
return `${start.toISOString()}..${end.toISOString()}`;
}
class GitHubClient {
private readonly apiBase = "https://api.github.com";
private readonly token: string;
constructor(token: string) {
this.token = token;
}
async getJson<T>(pathname: string): Promise<T> {
while (true) {
const response = await fetch(`${this.apiBase}${pathname}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.token}`,
"User-Agent": "paperclip-commit-metrics",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (response.ok) {
return (await response.json()) as T;
}
const retryAfter = response.headers.get("retry-after");
if ((response.status === 403 || response.status === 429) && retryAfter) {
const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000);
console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`);
await sleep(waitMs);
continue;
}
const remaining = response.headers.get("x-ratelimit-remaining");
const resetAt = response.headers.get("x-ratelimit-reset");
if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) {
const waitMs = Math.max(Number.parseInt(resetAt, 10) * 1000 - Date.now() + 1_000, 1_000);
console.error(`GitHub rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`);
await sleep(waitMs);
continue;
}
const body = await response.text();
throw new Error(`GitHub API request failed (${response.status}) for ${pathname}: ${body}`);
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View file

@ -32,18 +32,20 @@
"skills"
],
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts",
"dev": "pnpm run preflight:workspace-links && tsx src/index.ts",
"dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
"build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
"prepack": "pnpm run prepare:ui-dist",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
"start": "node dist/index.js",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
"typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@libsql/client": "^0.17.2",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
@ -65,7 +67,7 @@
"drizzle-orm": "^0.38.4",
"embedded-postgres": "^18.1.0-beta.16",
"express": "^5.1.0",
"hermes-paperclip-adapter": "0.1.1",
"hermes-paperclip-adapter": "^0.2.0",
"jsdom": "^28.1.0",
"multer": "^2.0.2",
"open": "^11.0.0",

View file

@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
const require = createRequire(import.meta.url);
const tsxCliPath = require.resolve("tsx/dist/cli.mjs");
const tsxCliPath = require.resolve("tsx/cli");
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);

View file

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
@ -76,6 +77,14 @@ describe("adapter model listing", () => {
expect(models).toEqual(cursorFallbackModels);
});
it("returns opencode fallback models including gpt-5.4", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
});
it("loads cursor models dynamically and caches them", async () => {
const runner = vi.fn(() => ({
status: 0,
@ -95,10 +104,4 @@ describe("adapter model listing", () => {
expect(first.some((model) => model.id === "composer-1")).toBe(true);
});
it("returns no opencode models when opencode command is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual([]);
});
});

View file

@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("agent_creator");
});
it("exposes a dedicated agent route for the inbox mine view", async () => {
mockIssueService.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
const app = createApp({
type: "agent",
agentId,
companyId,
runId: "run-1",
source: "agent_key",
});
const res = await request(app)
.get("/api/agents/me/inbox/mine")
.query({ userId: "board-user" });
expect(res.status).toBe(200);
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
touchedByUserId: "board-user",
inboxArchivedByUserId: "board-user",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
});
expect(res.body).toEqual([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
});
});

View file

@ -368,10 +368,10 @@ describe("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
"AGENTS.md": expect.stringContaining("You are the Project Manager for this Nexus workspace."),
"HEARTBEAT.md": expect.stringContaining("Project Manager Task Loop"),
"SOUL.md": expect.stringContaining("Project Manager Persona"),
"TOOLS.md": expect.stringContaining("# TOOLS.md"),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
@ -395,7 +395,7 @@ describe("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
"AGENTS.md": expect.stringContaining("You are a Senior Engineer in this Nexus workspace."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);

View file

@ -84,6 +84,28 @@ describe("boardMutationGuard", () => {
expect(res.status).toBe(204);
});
it("allows board mutations when x-forwarded-host matches origin", async () => {
const app = createApp("board");
const res = await request(app)
.post("/mutate")
.set("Host", "127.0.0.1")
.set("X-Forwarded-Host", "10.90.10.20:3443")
.set("Origin", "https://10.90.10.20:3443")
.send({ ok: true });
expect(res.status).toBe(204);
});
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
const app = createApp("board");
const res = await request(app)
.post("/mutate")
.set("Host", "127.0.0.1")
.set("X-Forwarded-Host", "10.90.10.20:3443")
.set("Origin", "https://evil.example.com")
.send({ ok: true });
expect(res.status).toBe(403);
});
it("does not block authenticated agent mutations", async () => {
const middleware = boardMutationGuard();
const req = {

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server";
import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
describe("claude_local max-turn detection", () => {
it("detects max-turn exhaustion by subtype", () => {
@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => {
).toBe(false);
});
});
describe("claude_local ui stdout parser", () => {
it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => {
const ts = "2026-03-29T00:00:00.000Z";
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "system",
subtype: "init",
model: "claude-sonnet-4-6",
session_id: "claude-session-1",
}),
ts,
),
).toEqual([
{
kind: "init",
ts,
model: "claude-sonnet-4-6",
sessionId: "claude-session-1",
},
]);
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "assistant",
session_id: "claude-session-1",
message: {
content: [
{ type: "text", text: "I will inspect the repo." },
{ type: "thinking", thinking: "Checking the adapter wiring" },
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
],
},
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I will inspect the repo." },
{ kind: "thinking", ts, text: "Checking the adapter wiring" },
{ kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } },
]);
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
is_error: false,
},
],
},
}),
ts,
),
).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "tool_1",
content: "AGENTS.md\nREADME.md",
isError: false,
},
]);
});
});
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("claude_local cli formatter", () => {
it("prints the user-visible and background transcript events from stream-json output", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
printClaudeStreamEvent(
JSON.stringify({
type: "system",
subtype: "init",
model: "claude-sonnet-4-6",
session_id: "claude-session-1",
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "text", text: "I will inspect the repo." },
{ type: "thinking", thinking: "Checking the adapter wiring" },
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
],
},
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
is_error: false,
},
],
},
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
total_cost_usd: 0.00042,
}),
false,
);
const lines = spy.mock.calls
.map((call) => call.map((value) => String(value)).join(" "))
.map(stripAnsi);
expect(lines).toEqual(
expect.arrayContaining([
"Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)",
"assistant: I will inspect the repo.",
"thinking: Checking the adapter wiring",
"tool_call: bash",
'{\n "command": "ls -1"\n}',
"tool_result",
"AGENTS.md\nREADME.md",
"result:",
"Done",
"tokens: in=10 out=5 cached=2 cost=$0.000420",
]),
);
} finally {
spy.mockRestore();
}
});
});

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