Compare commits

...
Sign in to create a new pull request.

118 commits

Author SHA1 Message Date
4c8cfcd851 [nexus] fix(audit): resolve integration checker findings — straggler strings, query param pre-fill, orphaned import
Some checks failed
Docker / build-and-push (push) Has been cancelled
2026-03-31 11:08:53 +02:00
104dd06036 [nexus] fix(04-03): add root directory prompt to CLI onboarding (ONBD-06) 2026-03-31 10:58:05 +02:00
c3e481230c 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-03-31 10:49:02 +02:00
baaa847236 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-03-31 10:48:53 +02:00
e9398a8777 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-03-31 10:48:16 +02:00
6d396a82de 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-03-31 10:46:56 +02:00
e894af8c02 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-03-31 10:38:05 +02:00
5855793d6d 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-03-31 10:37:04 +02:00
5b4a9543c7 [nexus] fix(03-05): replace remaining Paperclip/Companies display strings in BreadcrumbContext and CompanySwitcher 2026-03-31 10:08:38 +02:00
5a122129f9 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-03-31 09:56:12 +02:00
aafa56a63c 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-03-30 23:55:08 +02:00
469993a7b6 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-03-30 23:53:13 +02:00
930f9d876f 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-03-30 23:52:40 +02:00
b61ef7ba12 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-03-30 23:51:59 +02:00
276f99da85 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-03-30 23:50:37 +02:00
0b7c62b419 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-03-30 23:49:50 +02:00
1a50c7b632 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-03-30 23:44:47 +02:00
7c7d3749c3 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-03-30 23:43:47 +02:00
1e48ca0d3a 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-03-30 23:10:23 +02:00
dd63ecd1f7 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-03-30 23:08:44 +02:00
302b0d4ae7 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-03-30 23:06:46 +02:00
78538a7390 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-03-30 23:05:49 +02:00
260ecbb9d8 [nexus] chore(01-02): make install-hooks.sh executable 2026-03-30 22:33:55 +02:00
9459619da4 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-03-30 22:33:43 +02:00
f52e5eda55 [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-03-30 22:33:39 +02:00
3e7848ede3 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-03-30 22:32:47 +02:00
3a76d5f972 [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-03-30 22:32:41 +02:00
dotta
2e563ccd50 Move unread/archive column to the left for non-issue inbox items
Repositions the unread dot and archive X button to the leading
(left) side of approval, failed run, and join request rows,
matching the visual alignment of IssueRow where the unread slot
appears first due to CSS flex ordering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 16:09:43 -05:00
dotta
2c406d3b8c Extend read/dismissed functionality to all inbox item types
Approvals, failed runs, and join requests now have the same
unread dot + archive X pattern as issues in the Mine tab:
- Click the blue dot to mark as read, then X appears on hover
- Desktop: animated dismiss with scale/slide transition
- Mobile: swipe-to-archive via SwipeToArchive wrapper
- Dismissed items are filtered out of Mine tab
- Badge count excludes dismissed approvals and join requests
- localStorage-backed read/dismiss state for non-issue items

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 16:09:43 -05:00
dotta
49c7fb7fbd Unify unread badge and archive X into single column on Mine tab
The unread dot and dismiss X now share the same rightmost column on
the Mine tab.  When an issue is unread the blue dot shows first;
clicking it marks the issue as read and reveals the X on hover for
archiving.  Read/unread state stays in sync across all inbox tabs.
Desktop dismiss animation polished with scale + slide.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:09:43 -05:00
dotta
995f5b0b66 Add the inbox mine tab and archive flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 16:09:43 -05:00
Dotta
b34fa3b273
Merge pull request #1834 from paperclipai/fix/project-description-mentions
fix: improve embedded Postgres bootstrap and worktree init
2026-03-26 12:43:22 -05:00
dotta
9ddf960312 Harden dev-watch excludes for nested UI outputs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 12:35:19 -05:00
dotta
a8894799e4 Align worktree provision with worktree init
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 12:35:19 -05:00
dotta
76a692c260 Improve embedded Postgres bootstrap errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 12:35:19 -05:00
Dotta
5913706329
Merge pull request #1830 from paperclipai/pr/pap-891-ui-polish
ui: polish mentions, issue workspace details, and issue search
2026-03-26 12:02:28 -05:00
Dotta
b944293eda
Merge pull request #1829 from paperclipai/pr/pap-891-worktree-reliability
fix(worktree): harden provisioned worktree isolation and test fallback behavior
2026-03-26 12:01:47 -05:00
dotta
3c1ebed539 test(worktree): address embedded postgres helper review feedback
- probe host support on every platform instead of special-casing darwin
- re-export the db package helper from server and cli tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:56:05 -05:00
dotta
ab0d04ff7a fix(ui): address workspace card review feedback
- restore pre-run workspace configuration visibility
- require explicit save/cancel for workspace edits
- stabilize debounced issue search callback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:53:25 -05:00
Dotta
6073ac3145
Merge pull request #1827 from paperclipai/pr/pap-891-docs-refresh
docs: refresh adapter/runtime docs and deprecate bootstrapPromptTemplate
2026-03-26 11:46:44 -05:00
Dotta
3b329467eb
Merge pull request #1828 from paperclipai/pr/pap-891-release-automation-followups
chore(release): publish @paperclipai/ui from release automation
2026-03-26 11:46:10 -05:00
Dotta
aa5b2be907
Merge pull request #1831 from paperclipai/pr/pap-891-opencode-headless-prompts
fix(opencode): support headless permission prompt configuration
2026-03-26 11:43:01 -05:00
Dotta
dcb66eeae7
Merge pull request #1812 from paperclipai/docs/maintenance-20260326-public
docs: documentation accuracy update 2026-03-26
2026-03-26 11:17:43 -05:00
dotta
874fe5ec7d Publish @paperclipai/ui from release automation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:13:11 -05:00
dotta
c916626cef test: skip embedded postgres suites when initdb is unavailable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:39 -05:00
dotta
555f026c24 Avoid sibling worktree port collisions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:39 -05:00
dotta
e91da556ee updated reamde 2026-03-26 11:12:39 -05:00
dotta
ab82e3f022 Fix worktree runtime isolation recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:39 -05:00
dotta
c74cda1851 Fix worktree provision isolation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:39 -05:00
dotta
fcf3ba6974 Seed Paperclip env in provisioned worktrees
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:39 -05:00
dotta
ed62d58cb2 Fix headless OpenCode permission prompts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:35 -05:00
dotta
dd8c1ca3b2 Speed up issues page search responsiveness
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:29 -05:00
dotta
5ee4cd98e8 feat: move workspace info from properties panel to issue main pane
Display workspace branch, path, and status in a card on the issue main pane
instead of in the properties sidebar. Only shown for non-default (isolated)
workspaces. Edit controls are hidden behind an Edit toggle button.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:29 -05:00
dotta
a6ca3a9418 fix: enable @-mention autocomplete in new project description editor
The MarkdownEditor in NewProjectDialog was not receiving mention options,
so typing @ in the description field did nothing. Added agents query and
mentionOptions prop to match how NewIssueDialog handles mentions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:29 -05:00
dotta
0fd75aa579 fix: render mention autocomplete via portal to prevent overflow clipping
The mention suggestion dropdown was getting clipped when typing at the
end of a long description inside modals/dialogs because parent containers
had overflow-y-auto. Render it via createPortal to document.body with
fixed positioning and z-index 9999 so it always appears above all UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:29 -05:00
dotta
eaa765118f chore: mark bootstrapPromptTemplate as deprecated
Add @deprecated JSDoc and inline comments to bootstrapPromptTemplate
references in agent-instructions and company-portability services.
This field is superseded by the managed instructions bundle system.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:25 -05:00
dotta
ed73547fb6 docs: update SPEC work artifacts and deprecate bootstrapPromptTemplate
- SPEC: reflect that Paperclip now manages task-linked documents and
  attachments (issue documents, file attachments) instead of claiming
  it does not manage work artifacts
- agents-runtime: remove bootstrapPromptTemplate from recommended config,
  add deprecation notice, update minimal setup checklist

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:25 -05:00
dotta
692105e202 docs: update adapter list and repo map accuracy
- Add missing adapters (opencode_local, hermes_local, cursor, pi_local,
  openclaw_gateway) to agents-runtime.md
- Document bootstrapPromptTemplate in prompt templates section
- Update AGENTS.md repo map with packages/adapters, adapter-utils, plugins
- Fix troubleshooting section to reference all local CLI adapters

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:25 -05:00
dotta
01b550d61a docs: fix SPEC accuracy for adapters and backend
- align adapter list with current built-in adapters
- update backend framework references to Express
- remove outdated V1 not-supported template export claim
- clarify work artifact boundaries with issue documents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 11:12:25 -05:00
Devin Foley
c6364149b1
Add delegation instructions to default CEO agent prompt (#1796)
New CEO agents created during onboarding now include explicit delegation
rules: triage tasks, route to CTO/CMO/UXDesigner, never do IC work, and
follow up on delegated work.
2026-03-26 08:11:22 -07:00
dotta
844b6dfd70 docs: update SPEC work artifacts and deprecate bootstrapPromptTemplate
- SPEC: reflect that Paperclip now manages task-linked documents and
  attachments (issue documents, file attachments) instead of claiming
  it does not manage work artifacts
- agents-runtime: remove bootstrapPromptTemplate from recommended config,
  add deprecation notice, update minimal setup checklist

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 07:23:09 -05:00
dotta
0a32e3838a fix: render mention autocomplete via portal to prevent overflow clipping
The mention suggestion dropdown was getting clipped when typing at the
end of a long description inside modals/dialogs because parent containers
had overflow-y-auto. Render it via createPortal to document.body with
fixed positioning and z-index 9999 so it always appears above all UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 07:22:24 -05:00
dotta
e186449f94 docs: update adapter list and repo map accuracy
- Add missing adapters (opencode_local, hermes_local, cursor, pi_local,
  openclaw_gateway) to agents-runtime.md
- Document bootstrapPromptTemplate in prompt templates section
- Update AGENTS.md repo map with packages/adapters, adapter-utils, plugins
- Fix troubleshooting section to reference all local CLI adapters

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 07:08:45 -05:00
dotta
4bb42005ea docs: fix SPEC accuracy for adapters and backend
- align adapter list with current built-in adapters
- update backend framework references to Express
- remove outdated V1 not-supported template export claim
- clarify work artifact boundaries with issue documents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 07:08:45 -05:00
Dotta
66aa65f8f7
Merge pull request #1787 from HenkDz/fix/pi-adapter-models-stderr
fix(pi-local): parse models from stderr
2026-03-26 07:04:38 -05:00
HenkDz
15f6079c6b Fix Pi adapter execution and improve transcript parsing
- Changed from RPC mode to JSON print mode (--mode json -p)
- Added prompt as CLI argument instead of stdin RPC command
- Rewrote transcript parser to properly handle Pi's JSONL output
- Added toolUseId to tool_call entries for proper matching with tool_result
- Filter out RPC protocol messages from transcript
- Extract thinking blocks and usage statistics
2026-03-26 10:59:58 +01:00
Devin Foley
9e9eec9af6
ci: validate Dockerfile deps stage in PR policy (#1799)
* ci: add Dockerfile deps stage validation to PR policy

Checks that all workspace package.json files and the patches/
directory are copied into the Dockerfile deps stage. Prevents the
Docker build from breaking when new packages or patches are added
without updating the Dockerfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: scope Dockerfile check to deps stage and derive workspace roots

Address Greptile review feedback:
- Use awk to extract only the deps stage before grepping, preventing
  false positives from COPY lines in other stages
- Derive workspace search roots from pnpm-workspace.yaml instead of
  hardcoding them, so new top-level workspaces are automatically covered

* ci: guard against empty workspace roots in Dockerfile check

Fail early if pnpm-workspace.yaml parsing yields no search roots,
preventing a silent false-pass from find defaulting to cwd.

* ci: guard against empty deps stage extraction

Fail early with a clear error if awk cannot find the deps stage in the
Dockerfile, instead of producing misleading "missing COPY" errors.

* ci: deduplicate find results from overlapping workspace roots

Use sort -u instead of sort to prevent duplicate error messages when
nested workspace globs (e.g. packages/* and packages/adapters/*) cause
the same package.json to be found twice.

* ci: anchor grep to ^COPY to ignore commented-out Dockerfile lines

Prevents false negatives when a COPY directive is commented out
(e.g. # COPY packages/foo/package.json).

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:42:16 -07:00
Devin Foley
1a4ed8c953
Merge pull request #1794 from paperclipai/fix/cursor-native-auth-check
fix(cursor): check native auth before warning about missing API key
2026-03-25 22:00:37 -07:00
Devin Foley
bd60ea4909 refactor: use async fs.readFile in readCursorAuthInfo for consistency
Match the async pattern used by readCodexAuthInfo in the Codex adapter.
2026-03-25 21:52:38 -07:00
Devin Foley
6ebfc0ff3d
Merge pull request #1782 from paperclipai/fix/codex-skill-injection-location
fix(codex): inject skills into ~/.codex/skills/ instead of workspace
2026-03-25 21:44:58 -07:00
Devin Foley
083d7c9ac4 fix(cursor): check native auth before warning about missing API key
When CURSOR_API_KEY is not set, check ~/.cursor/cli-config.json for
authInfo from `agent login` before emitting the missing key warning.
Users authenticated via native login no longer see a false warning.
2026-03-25 20:54:16 -07:00
Devin Foley
80766e589c Clarify docs: skills go to the effective CODEX_HOME, not ~/.codex
The previous documentation parenthetical "(defaulting to ~/.codex/skills/)"
was misleading because Paperclip almost always sets CODEX_HOME to a
per-company managed home.  Update index.ts docs, skills.ts detail string,
and execute.ts inline comment to make the runtime path unambiguous.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 20:46:05 -07:00
Devin Foley
c5c6c62bd7
Merge pull request #1786 from paperclipai/fix/opencode-disable-project-config
fix(opencode): prevent opencode.json pollution in workspace
2026-03-25 20:38:11 -07:00
Devin Foley
1549799c1e Move OPENCODE_DISABLE_PROJECT_CONFIG after envConfig loop
Setting the env var before the user-config loop meant adapter env
overrides could disable the guard.  Move it after the loop so it
always wins, matching the pattern already used in test.ts and
models.ts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 20:29:48 -07:00
HenkDz
af1b08fdf4 fix(pi-local): parse models from stderr
Pi outputs the model list to stderr instead of stdout. This fix checks
stderr first and falls back to stdout for compatibility with older
versions.

Fixes model discovery returning empty arrays and environment tests
failing with 'Pi returned no models' error.
2026-03-26 01:30:54 +01:00
Devin Foley
72bc4ab403 fix(opencode): prevent opencode.json config pollution in workspace
Set OPENCODE_DISABLE_PROJECT_CONFIG=true in all OpenCode invocations
(execute, model discovery, environment test) to stop the OpenCode CLI
from writing an opencode.json file into the project working directory.
Model selection is already passed via the --model CLI flag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 17:22:49 -07:00
Devin Foley
4c6b9c190b Fix stale reference to resolveCodexSkillsHome in fallback path
The default fallback in ensureCodexSkillsInjected still referenced the
old function name. Updated to use resolveCodexSkillsDir with shared
home as fallback.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 16:09:09 -07:00
Devin Foley
f6ac6e47c4 Clarify docs: skills go to $CODEX_HOME/skills/, defaulting to ~/.codex
Addresses Greptile P2 review comment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 16:05:57 -07:00
Devin Foley
623ab1c3ea Fix skill injection to use effective CODEX_HOME, not shared home
The previous commit incorrectly used resolveSharedCodexHomeDir() (~/.codex)
but Codex runs with CODEX_HOME set to a per-company managed home under
~/.paperclip/instances/. Skills injected into ~/.codex/skills/ would not
be discoverable by Codex. Now uses effectiveCodexHome directly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 16:04:53 -07:00
Devin Foley
eeec52ad74 Fix Codex skill injection to use ~/.codex/skills/ instead of cwd
The Codex adapter was the only one injecting skills into
<cwd>/.agents/skills/, polluting the project's git repo. All other
adapters (Gemini, Cursor, etc.) use a home-based directory. This
changes the Codex adapter to inject into ~/.codex/skills/ (resolved
via resolveSharedCodexHomeDir) to match the established pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 15:55:51 -07:00
Dotta
db3883d2e7
Merge pull request #1748 from paperclipai/pr/pap-849-release-changelog
Some checks failed
Docker / build-and-push (push) Has been cancelled
docs(release): add v2026.325.0 changelog
2026-03-25 07:53:38 -05:00
dotta
9637351880 docs(release): add v2026.325.0 changelog
Summarizes the v2026.325.0 release with highlights, fixes, upgrade notes, and contributor credits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 07:42:38 -05:00
Devin Foley
cbca599625
Merge pull request #1363 from amit221/fix/issue-1255
fix(issues): normalize HTML entities in @mention tokens before agent lookup
2026-03-24 20:19:47 -07:00
Devin Foley
b1d12d2f37
Merge pull request #1730 from paperclipai/fix/docker-patches
fix(docker): copy patches directory into deps stage
2026-03-24 16:13:24 -07:00
Devin Foley
0a952dc93d fix(docker): copy patches directory into deps stage
pnpm install needs the patches/ directory to resolve patched
dependencies (embedded-postgres). Without it, --frozen-lockfile
fails with ENOENT on the patch file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:59:36 -07:00
Devin Foley
ff8b839f42
Merge pull request #1658 from paperclipai/fix/migration-auto-apply-precedence
fix(server): check MIGRATION_AUTO_APPLY before MIGRATION_PROMPT
2026-03-24 15:56:00 -07:00
Devin Foley
fea892c8b3
Merge pull request #1702 from paperclipai/fix/codex-auth-check
fix(codex): check native auth before warning about missing API key
2026-03-24 15:40:20 -07:00
Devin Foley
1696ff0c3f fix(codex): use path.join for auth detail message path
Use path.join instead of string concatenation for the auth.json
fallback path in the detail message, ensuring correct path
separators on Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:27:32 -07:00
Devin Foley
4eecd23ea3 fix(codex): use codexHomeDir() fallback for accurate auth detail path
When adapter config has no CODEX_HOME but process.env.CODEX_HOME is
set, readCodexAuthInfo reads from the process env path. The detail
message now uses codexHomeDir() instead of hardcoded "~/.codex" so
the displayed path always matches where credentials were read from.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:46:16 -07:00
Devin Foley
4da83296a9 test(codex): move OPENAI_API_KEY stub to beforeEach for all tests
Consolidate the env stub into beforeEach so the pre-existing cwd
test is also isolated from host OPENAI_API_KEY, avoiding
non-deterministic filesystem side effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:33:11 -07:00
Devin Foley
0ce4134ce1 fix(codex): use actual CODEX_HOME in auth detail message
Show the configured CODEX_HOME path instead of hardcoded ~/.codex
when the email fallback message is displayed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:54:05 -07:00
Dotta
03f44d0089
Merge pull request #1708 from paperclipai/pr/pap-817-onboarding-goal-context
Seed onboarding project and issue goal context
2026-03-24 12:38:19 -05:00
Dotta
d38d5e1a7b
Merge pull request #1710 from paperclipai/pr/pap-817-agent-mention-pill-alignment
Fix agent mention pill vertical misalignment with project mention pill
2026-03-24 12:33:51 -05:00
Dotta
add6ca5648
Merge pull request #1709 from paperclipai/pr/pap-817-join-request-task-assignment-grants
Preserve task assignment grants for joined agents
2026-03-24 12:33:40 -05:00
github-actions[bot]
04a07080af
chore(lockfile): refresh pnpm-lock.yaml (#1712)
Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-03-24 17:33:24 +00:00
Dotta
8bebc9599a
Merge pull request #1707 from paperclipai/pr/pap-817-embedded-postgres-docker-initdb
Fix embedded Postgres initdb failure in Docker slim containers
2026-03-24 12:32:57 -05:00
Dotta
6250d536a0
Merge pull request #1706 from paperclipai/pr/pap-817-inline-join-requests-inbox
Render join requests inline in inbox like approvals and other work items
2026-03-24 12:30:43 -05:00
Dotta
de5985bb75
Merge pull request #1705 from paperclipai/pr/pap-817-remove-instructions-log
Remove noisy "Loaded agent instructions file" log from all adapters
2026-03-24 12:30:15 -05:00
Dotta
331e1f0d06
Merge pull request #1704 from paperclipai/pr/pap-817-cli-api-connection-errors
Improve CLI API connection errors
2026-03-24 12:30:06 -05:00
Devin Foley
58c511af9a test(codex): isolate auth tests from host OPENAI_API_KEY
Use vi.stubEnv to clear OPENAI_API_KEY in both new tests so they
don't silently pass the wrong branch when the key is set in the
test runner's environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 09:58:40 -07:00
dotta
4b668379bc Regenerate embedded-postgres vendor patch
Rebuild the patch file with valid unified-diff hunks so pnpm can
apply the locale and environment fixes during install.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:56:41 -05:00
dotta
f352f3f514 Force embedded-postgres messages locale to C
The vendor package still hardcoded LC_MESSAGES to en_US.UTF-8.
That locale is missing in slim containers, and initdb fails during
bootstrap even when --lc-messages=C is passed later.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:55:39 -05:00
dotta
4ff460de38 Fix embedded-postgres patch env lookup
Use globalThis.process.env in the vendor patch so the spawned child
process config does not trip over the local process binding inside
embedded-postgres.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:51:45 -05:00
Devin Foley
06b85d62b2 test(codex): add coverage for native auth detection in environment probe
Add tests for codex_native_auth_present and codex_openai_api_key_missing
code paths. Also pass adapter-configured CODEX_HOME through to
readCodexAuthInfo so the probe respects per-adapter home directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 09:51:44 -07:00
dotta
3447e2087a Fix agent mention pill vertical misalignment with project mention pill
Change vertical-align from baseline to middle on both editor and
read-only mention chip styles. The baseline alignment caused
inconsistent positioning because the agent ::before icon (0.75rem)
and project ::before dot (0.45rem) produced different synthesized
baselines in the inline-flex containers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:49:00 -05:00
dotta
44fbf83106 Preserve task assignment grants for joined agents 2026-03-24 11:49:00 -05:00
dotta
eb73fc747a Seed onboarding project and issue goal context
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:48:59 -05:00
dotta
5602576ae1 Fix embedded Postgres initdb failure in Docker slim containers
The embedded-postgres library hardcodes --lc-messages=en_US.UTF-8 and
strips the parent process environment when spawning initdb/postgres.
In slim Docker images (e.g. node:20-bookworm-slim), the en_US.UTF-8
locale isn't installed, causing initdb to exit with code 1.

Two fixes applied:
1. Add --lc-messages=C to all initdbFlags arrays (overrides the
   library's hardcoded locale since our flags come after in the spread)
2. pnpm patch on embedded-postgres to preserve process.env in spawn
   calls, preventing loss of PATH, LD_LIBRARY_PATH, and other vars

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:48:59 -05:00
dotta
c4838cca6e Render join requests inline in inbox like approvals and other work items
Join requests were displayed in a separate card-style section below the main
inbox list. This moves them into the unified work items feed so they sort
chronologically alongside issues, approvals, and failed runs—matching the
inline treatment hiring requests already receive.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:48:43 -05:00
dotta
67841a0c6d Remove noisy "Loaded agent instructions file" log from all adapters
Loading an instructions file is normal, expected behavior — not worth
logging to stdout/stderr on every run. Warning logs for failed reads
are preserved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 11:48:43 -05:00
Devin Foley
a9dcea023b fix(codex): check native auth before warning about missing API key
The environment test warned about OPENAI_API_KEY being unset even
when Codex was authenticated via `codex auth`. Now checks
~/.codex/auth.json before emitting the warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 09:47:48 -07:00
amit221
14ffbe30a0 test(issues): shorten mid-token entity test comment
Made-with: Cursor
2026-03-24 15:39:59 +02:00
amit221
98a5e287ef test(issues): document Greptile mid-token case vs old strip behavior
Made-with: Cursor
2026-03-24 15:29:28 +02:00
amit221
2735ef1f4a fix(issues): decode @mention entities without lockfile or new deps
- Drop entities package (CI blocks pnpm-lock.yaml on PRs; reset lockfile to master)
- Restore numeric + allowlisted named entity decoding in issues.ts
- Split Greptile mid-token &amp; case into its own test with review comment

Made-with: Cursor
2026-03-24 15:22:21 +02:00
amit221
53f0988006 Merge origin/master into fix/issue-1255
- findMentionedAgents: keep normalizeAgentMentionToken + extractAgentMentionIds
- decode @mention tokens with entities.decodeHTMLStrict (full HTML entities)
- Add entities dependency; expand unit tests for Greptile follow-ups

Made-with: Cursor
2026-03-24 10:03:15 +02:00
amit221
730a67bb20 fix(issues): decode HTML entities in @mention tokens instead of stripping
Addresses Greptile review on PR #1363: numeric entities decode via
code points; named entities use a small allowlist (amp, nbsp, etc.)
so M&amp;M resolves correctly; unknown named entities are preserved.

Adds mid-token tests for &amp; in agent names.

Made-with: Cursor
2026-03-24 09:40:55 +02:00
Devin Foley
dd1d9bed80 fix(server): check MIGRATION_AUTO_APPLY before MIGRATION_PROMPT
PAPERCLIP_MIGRATION_PROMPT=never was checked before
PAPERCLIP_MIGRATION_AUTO_APPLY=true, causing auto-apply to never
trigger when both env vars are set (as in dev:watch). Swap the
check order so AUTO_APPLY takes precedence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:14:14 -07:00
Paperclip Dev
47449152ac fix(issues): normalize HTML entities in @mention tokens before agent lookup (#1255)
Rich-text comments store entities like &#x20; after @names; strip them before matching agents so issue_comment_mentioned and wake injection work.

Made-with: Cursor
2026-03-20 16:38:55 +00:00
180 changed files with 19130 additions and 1817 deletions

View file

@ -40,6 +40,46 @@ jobs:
with:
node-version: 24
- name: Validate Dockerfile deps stage
run: |
missing=0
# Extract only the deps stage from the Dockerfile
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
if [ -z "$deps_stage" ]; then
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
exit 1
fi
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
if [ -z "$search_roots" ]; then
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
exit 1
fi
# Check all workspace package.json files are copied in the deps stage
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
dir="$(dirname "$pkg")"
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
missing=1
fi
done
# Check patches directory is copied if it exists
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
missing=1
fi
if [ "$missing" -eq 1 ]; then
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
exit 1
fi
- name: Validate dependency resolution when manifests change
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"

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

@ -26,6 +26,9 @@ Before making changes, read in this order:
- `ui/`: React + Vite board UI
- `packages/db/`: Drizzle schema, migrations, DB clients
- `packages/shared/`: shared types, constants, validators, API path constants
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
- `packages/adapter-utils/`: shared adapter utilities
- `packages/plugins/`: plugin system packages
- `doc/`: operational and product docs
## 4. Dev Setup (Auto DB)

View file

@ -21,6 +21,7 @@ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY patches/ patches/
RUN pnpm install --frozen-lockfile

View file

@ -234,16 +234,27 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
## Roadmap
- ⚪ Get OpenClaw onboarding easier
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
- ⚪ ClipMart - buy and sell entire agent companies
- ⚪ Easy agent configurations / easier to understand
- ⚪ Better support for harness engineering
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
- ⚪ Better docs
- ✅ 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](CONTRIBUTING.md) for details.

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

@ -6,33 +6,15 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { createStoredZipArchive } from "./helpers/zip.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
const execFileAsync = promisify(execFile);
type ServerProcess = ReturnType<typeof spawn>;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
@ -53,30 +35,13 @@ async function getAvailablePort(): Promise<number> {
});
}
async function startTempDatabase() {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db");
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
@ -265,26 +230,23 @@ async function waitForServer(
);
}
describe("paperclipai company import/export e2e", () => {
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
let tempRoot = "";
let configPath = "";
let exportDir = "";
let apiBase = "";
let serverProcess: ServerProcess | null = null;
let dbDataDir = "";
let dbInstance: EmbeddedPostgresInstance | null = null;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
configPath = path.join(tempRoot, "config", "config.json");
exportDir = path.join(tempRoot, "exported-company");
const db = await startTempDatabase();
dbDataDir = db.dataDir;
dbInstance = db.instance;
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
const port = await getAvailablePort();
writeTestConfig(configPath, tempRoot, port, db.connectionString);
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
apiBase = `http://127.0.0.1:${port}`;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => {
["paperclipai", "run", "--config", configPath],
{
cwd: repoRoot,
env: createServerEnv(configPath, port, db.connectionString),
env: createServerEnv(configPath, port, tempDb.connectionString),
stdio: ["ignore", "pipe", "pipe"],
},
);
@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => {
afterAll(async () => {
await stopServerProcess(serverProcess);
await dbInstance?.stop();
if (dbDataDir) {
rmSync(dbDataDir, { recursive: true, force: true });
}
await tempDb?.cleanup();
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}

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

@ -0,0 +1,6 @@
export {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestSupport,
} from "@paperclipai/db";

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

@ -344,6 +344,87 @@ describe("worktree helpers", () => {
}
});
it("avoids ports already claimed by sibling worktree instance configs", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
const repoRoot = path.join(tempRoot, "repo");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
fs.writeFileSync(
path.join(siblingInstanceRoot, "config.json"),
JSON.stringify(
{
...buildSourceConfig(),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
embeddedPostgresPort: 54330,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(siblingInstanceRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(siblingInstanceRoot, "logs"),
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: ["localhost"],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(siblingInstanceRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
);
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: homeDir,
});
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
expect(config.server.port).toBe(3102);
expect(config.database.embeddedPostgresPort).not.toBe(54330);
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("defaults the seed source config to the current repo-local Paperclip config", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
const repoRoot = path.join(tempRoot, "repo");

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

@ -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

@ -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,7 +79,7 @@ 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" },
@ -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,7 +1041,7 @@ 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

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(
@ -309,7 +393,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 +531,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 +557,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 +582,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

@ -41,13 +41,15 @@ import {
projects,
runDatabaseBackup,
runDatabaseRestore,
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
} from "@paperclipai/db";
import type { Command } from "commander";
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
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,
@ -465,6 +467,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set<numbe
return port;
}
function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
const normalized = path.resolve(cwd);
const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`;
const index = normalized.indexOf(marker);
if (index === -1) return null;
const repoRoot = normalized.slice(0, index);
return path.resolve(repoRoot, ".paperclip", "worktrees");
}
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
serverPorts: Set<number>;
databasePorts: Set<number>;
} {
const serverPorts = new Set<number>();
const databasePorts = new Set<number>();
const configPaths = new Set<string>();
const instancesDir = path.resolve(homeDir, "instances");
if (existsSync(instancesDir)) {
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === currentInstanceId) continue;
const configPath = path.resolve(instancesDir, entry.name, "config.json");
if (existsSync(configPath)) {
configPaths.add(configPath);
}
}
}
const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd);
if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) {
for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json");
if (existsSync(configPath)) {
configPaths.add(configPath);
}
}
}
for (const configPath of configPaths) {
try {
const config = readConfig(configPath);
if (config?.server.port) {
serverPorts.add(config.server.port);
}
if (config?.database.mode === "embedded-postgres") {
databasePorts.add(config.database.embeddedPostgresPort);
}
} catch {
// Ignore malformed sibling configs.
}
}
return { serverPorts, databasePorts };
}
function detectGitBranchName(cwd: string): string | null {
try {
const value = execFileSync("git", ["branch", "--show-current"], {
@ -750,24 +808,39 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
}
const port = await findAvailablePort(preferredPort);
const logBuffer = createEmbeddedPostgresLogBuffer();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: logBuffer.append,
onError: logBuffer.append,
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
try {
await instance.initialise();
} catch (error) {
throw formatEmbeddedPostgresError(error, {
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
}
await instance.start();
try {
await instance.start();
} catch (error) {
throw formatEmbeddedPostgresError(error, {
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
return {
port,
@ -886,10 +959,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
const serverPort = await findAvailablePort(preferredServerPort);
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
const databasePort = await findAvailablePort(
preferredDbPort,
new Set([...claimedPorts.databasePorts, serverPort]),
);
const targetConfig = buildWorktreeConfig({
sourceConfig,
paths,
@ -969,13 +1046,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);
@ -1171,7 +1248,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);

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

@ -206,6 +206,17 @@ paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force
```
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
```sh
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
pnpm paperclipai worktree init --force --seed-mode minimal \
--name PAP-884-ai-commits-component \
--from-config ~/.paperclip/instances/default/config.json
```
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |

View file

@ -51,10 +51,9 @@ Public packages are discovered from:
- `packages/`
- `server/`
- `ui/`
- `cli/`
`ui/` is ignored because it is private.
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
- finds all public packages
@ -65,6 +64,18 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
Those rewrites are temporary. The working tree is restored after publish or dry-run.
## `@paperclipai/ui` packaging
The UI package publishes prebuilt static assets, not the source workspace.
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
- keeps the release-managed `name` and `version`
- publishes only `dist/`
- omits the source-only dependency graph from downstream installs
After packing or publishing, `postpack` restores the development manifest automatically.
## Version formats
Paperclip uses calendar versions:
@ -135,6 +146,7 @@ This is the fastest way to restore the default install path if a stable release
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

View file

@ -35,6 +35,7 @@ At minimum that includes:
- `paperclipai`
- `@paperclipai/server`
- `@paperclipai/ui`
- public packages under `packages/`
### 2.1. In npm, open each package settings page

View file

@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
### Execution Adapters
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
| Adapter | Mechanism | Example |
| -------------------- | ----------------------- | --------------------------------------------- |
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
| `hermes_local` | Hermes agent process | Local Hermes agent |
| Adapter | Mechanism | Example |
| ---------------- | -------------------------- | -------------------------------------------------- |
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
### Adapter Interface
@ -376,7 +380,7 @@ Flow:
| Layer | Technology |
| -------- | ------------------------------------------------------------ |
| Frontend | React + Vite |
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
| Auth | [Better Auth](https://www.better-auth.com/) |
@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
### Work Artifacts
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
### Open Questions
@ -476,15 +480,14 @@ Each is a distinct page/route:
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
- [ ] **Default CEO** — strategic planning, delegation, board communication
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
- [ ] **REST API** — full API for agent interaction (Hono)
- [ ] **REST API** — full API for agent interaction (Express)
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
- [ ] **Agent auth** — connection string generation with URL + key + instructions
- [ ] **One-command dev setup** — embedded PGlite, everything local
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
### Not V1
- Template export/import
- Knowledge base - a future plugin
- Advanced governance models (hiring budgets, multi-member boards)
- Revenue/expense tracking beyond token costs - a future plugin
@ -509,7 +512,7 @@ Things Paperclip explicitly does **not** do:
- **Not a SaaS** — single-tenant, self-hosted
- **Not opinionated about Agent implementation** — any language, any framework, any runtime
- **Not automatically self-healing** — surfaces problems, doesn't silently fix them
- **Does not manage work artifacts** — no repo management, no deployment, no file systems
- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments)
- **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed
- **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core.

View file

@ -1,7 +1,7 @@
# Agent Runtime Guide
Status: User-facing guide
Last updated: 2026-02-17
Status: User-facing guide
Last updated: 2026-03-26
Audience: Operators setting up and running agents in Paperclip
## 1. What this system does
@ -32,14 +32,19 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la
## 3.1 Adapter choice
Common choices:
Built-in adapters:
- `claude_local`: runs your local `claude` CLI
- `codex_local`: runs your local `codex` CLI
- `opencode_local`: runs your local `opencode` CLI
- `hermes_local`: runs your local `hermes` CLI
- `cursor`: runs Cursor in background mode
- `pi_local`: runs an embedded Pi agent locally
- `openclaw_gateway`: connects to an OpenClaw gateway endpoint
- `process`: generic shell command adapter
- `http`: calls an external HTTP endpoint
For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine.
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
## 3.2 Runtime behavior
@ -69,6 +74,8 @@ You can set:
Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values.
> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system.
## 4. Session resume behavior
Paperclip stores session IDs for resumable adapters.
@ -133,7 +140,7 @@ If the connection drops, the UI reconnects automatically.
If runs fail repeatedly:
1. Check adapter command availability (`claude`/`codex` installed and logged in).
1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in).
2. Verify `cwd` exists and is accessible.
3. Inspect run error + stderr excerpt, then full log.
4. Confirm timeout is not too low.
@ -166,9 +173,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r
## 10. Minimal setup checklist
1. Choose adapter (`claude_local` or `codex_local`).
2. Set `cwd` to the target workspace.
3. Add bootstrap + normal prompt templates.
1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`).
2. Set `cwd` to the target workspace (for local adapters).
3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle.
4. Configure heartbeat policy (timer and/or assignment wakeups).
5. Trigger a manual wakeup.
6. Confirm run succeeds and session/token usage is recorded.

View file

@ -35,8 +35,8 @@
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
},
"devDependencies": {
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
@ -44,5 +44,10 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@9.15.4"
"packageManager": "pnpm@9.15.4",
"pnpm": {
"patchedDependencies": {
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
}
}
}

View file

@ -352,7 +352,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const combinedPath = path.join(skillsDir, "agent-instructions.md");
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
effectiveInstructionsFilePath = combinedPath;
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -42,7 +42,7 @@ Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.

View file

@ -21,7 +21,7 @@ import {
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -135,8 +135,8 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks(
}
}
function resolveCodexWorkspaceSkillsDir(cwd: string): string {
return path.join(cwd, ".agents", "skills");
function resolveCodexSkillsDir(codexHome: string): string {
return path.join(codexHome, "skills");
}
type EnsureCodexSkillsInjectedOptions = {
@ -157,7 +157,7 @@ export async function ensureCodexSkillsInjected(
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd());
const skillsHome = options.skillsHome ?? resolveCodexSkillsDir(resolveSharedCodexHomeDir());
await fs.mkdir(skillsHome, { recursive: true });
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
@ -273,11 +273,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
// Inject skills into the same CODEX_HOME that Codex will actually run with
// (managed home in the default case, or an explicit override from adapter config).
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
await ensureCodexSkillsInjected(
onLog,
{
skillsHome: codexWorkspaceSkillsDir,
skillsHome: codexSkillsDir,
skillsEntries: codexSkillEntries,
desiredSkillNames,
},
@ -415,10 +417,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -107,8 +107,8 @@ function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string
return { email: null, planType: null };
}
export async function readCodexAuthInfo(): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHomeDir(), "auth.json");
export async function readCodexAuthInfo(codexHome?: string): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHome ?? codexHomeDir(), "auth.json");
let raw: string;
try {
raw = await fs.readFile(authPath, "utf8");

View file

@ -31,7 +31,7 @@ async function buildCodexSkillSnapshot(
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be linked into the workspace .agents/skills directory on the next run."
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,

View file

@ -15,6 +15,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -108,12 +109,23 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
});
} else {
checks.push({
code: "codex_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
});
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
if (codexAuth) {
checks.push({
code: "codex_native_auth_present",
level: "info",
message: "Codex is authenticated via its own auth configuration.",
detail: codexAuth.email ? `Logged in as ${codexAuth.email}.` : `Credentials found in ${path.join(codexHome ?? codexHomeDir(), "auth.json")}.`,
});
} else {
checks.push({
code: "codex_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or run `codex auth` to log in.",
});
}
}
const canRunProbe =

View file

@ -307,10 +307,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -12,6 +12,8 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
@ -49,6 +51,41 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
return clean.length > max ? `${clean.slice(0, max - 1)}` : clean;
}
export interface CursorAuthInfo {
email: string | null;
displayName: string | null;
userId: number | null;
}
export function cursorConfigPath(cursorHome?: string): string {
return path.join(cursorHome ?? path.join(os.homedir(), ".cursor"), "cli-config.json");
}
export async function readCursorAuthInfo(cursorHome?: string): Promise<CursorAuthInfo | null> {
let raw: string;
try {
raw = await fs.readFile(cursorConfigPath(cursorHome), "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
const authInfo = obj.authInfo;
if (typeof authInfo !== "object" || authInfo === null) return null;
const info = authInfo as Record<string, unknown>;
const email = typeof info.email === "string" && info.email.trim().length > 0 ? info.email.trim() : null;
const displayName = typeof info.displayName === "string" && info.displayName.trim().length > 0 ? info.displayName.trim() : null;
const userId = typeof info.userId === "number" ? info.userId : null;
if (!email && !displayName && userId == null) return null;
return { email, displayName, userId };
}
const CURSOR_AUTH_REQUIRED_RE =
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
@ -109,12 +146,25 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
});
} else {
checks.push({
code: "cursor_api_key_missing",
level: "warn",
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
});
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
if (cursorAuth) {
checks.push({
code: "cursor_native_auth_present",
level: "info",
message: "Cursor is authenticated via `agent login`.",
detail: cursorAuth.email
? `Logged in as ${cursorAuth.email}.`
: `Credentials found in ${cursorConfigPath(cursorHome)}.`,
});
} else {
checks.push({
code: "cursor_api_key_missing",
level: "warn",
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
});
}
}
const canRunProbe =

View file

@ -253,10 +253,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -22,6 +22,7 @@ Core fields:
- 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)
- 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"
- extraArgs (string[], optional): additional CLI args
@ -37,4 +38,10 @@ Notes:
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
- Runs are executed with: opencode run --format json ...
- Sessions are resumed with --session when stored session cwd matches current cwd.
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
writing an opencode.json config file into the project working directory. Model \
selection is passed via the --model CLI flag instead.
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
runtime config with \`permission.external_directory=allow\` so headless runs do \
not stall on approval prompts.
`;

View file

@ -23,6 +23,7 @@ import {
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -169,238 +170,247 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
// Prevent OpenCode from writing an opencode.json config file into the
// project working directory (which would pollute the git repo). Model
// selection is already handled via the --model CLI flag. Set after the
// envConfig loop so user overrides cannot disable this guard.
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
try {
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
await ensureCommandResolvable(command, cwd, runtimeEnv);
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stdout",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
];
}
return [
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
if (resumeSessionId) args.push("--session", resumeSessionId);
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command,
cwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
stdin: prompt,
timeoutSec,
graceSec,
onSpawn,
onLog,
});
return {
proc,
rawStderr: proc.stderr,
parsed: parseOpenCodeJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stdout",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
const commandNotes = (() => {
const notes = [...preparedRuntimeConfig.notes];
if (!resolvedInstructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
notes.push(
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
);
return notes;
})();
return {
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
};
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stdout",
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
if (resumeSessionId) args.push("--session", resumeSessionId);
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command,
cwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(preparedRuntimeConfig.env),
prompt,
promptMetrics,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env: runtimeEnv,
stdin: prompt,
timeoutSec,
graceSec,
onSpawn,
onLog,
});
return {
proc,
rawStderr: proc.stderr,
parsed: parseOpenCodeJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
return {
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
};
};
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stdout",
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
}
return toResult(initial);
} finally {
await preparedRuntimeConfig.cleanup();
}
return toResult(initial);
}

View file

@ -120,7 +120,8 @@ export async function discoverOpenCodeModels(input: {
// /etc/passwd entry (e.g. `docker run --user 1234` with a minimal
// image). Fall back to process.env.HOME.
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}) }));
// Prevent OpenCode from writing an opencode.json into the working directory.
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}), OPENCODE_DISABLE_PROJECT_CONFIG: "true" }));
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,

View file

@ -0,0 +1,79 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
const cleanupPaths = new Set<string>();
afterEach(async () => {
await Promise.all(
[...cleanupPaths].map(async (filepath) => {
await fs.rm(filepath, { recursive: true, force: true });
cleanupPaths.delete(filepath);
}),
);
});
async function makeConfigHome(initialConfig?: Record<string, unknown>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-"));
cleanupPaths.add(root);
const configDir = path.join(root, "opencode");
await fs.mkdir(configDir, { recursive: true });
if (initialConfig) {
await fs.writeFile(
path.join(configDir, "opencode.json"),
`${JSON.stringify(initialConfig, null, 2)}\n`,
"utf8",
);
}
return root;
}
describe("prepareOpenCodeRuntimeConfig", () => {
it("injects an external_directory allow rule by default", async () => {
const configHome = await makeConfigHome({
permission: {
read: "allow",
},
theme: "system",
});
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: {},
});
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome);
const runtimeConfig = JSON.parse(
await fs.readFile(
path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
"utf8",
),
) as Record<string, unknown>;
expect(runtimeConfig).toMatchObject({
theme: "system",
permission: {
read: "allow",
external_directory: "allow",
},
});
await prepared.cleanup();
cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME);
await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow();
});
it("respects explicit opt-out", async () => {
const configHome = await makeConfigHome();
const prepared = await prepareOpenCodeRuntimeConfig({
env: { XDG_CONFIG_HOME: configHome },
config: { dangerouslySkipPermissions: false },
});
expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome });
expect(prepared.notes).toEqual([]);
await prepared.cleanup();
});
});

View file

@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { asBoolean } from "@paperclipai/adapter-utils/server-utils";
type PreparedOpenCodeRuntimeConfig = {
env: Record<string, string>;
notes: string[];
cleanup: () => Promise<void>;
};
function resolveXdgConfigHome(env: Record<string, string>): string {
return (
(typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) ||
(typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) ||
path.join(os.homedir(), ".config")
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readJsonObject(filepath: string): Promise<Record<string, unknown>> {
try {
const raw = await fs.readFile(filepath, "utf8");
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
}
export async function prepareOpenCodeRuntimeConfig(input: {
env: Record<string, string>;
config: Record<string, unknown>;
}): Promise<PreparedOpenCodeRuntimeConfig> {
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
if (!skipPermissions) {
return {
env: input.env,
notes: [],
cleanup: async () => {},
};
}
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json");
await fs.mkdir(runtimeConfigDir, { recursive: true });
try {
await fs.cp(sourceConfigDir, runtimeConfigDir, {
recursive: true,
force: true,
errorOnExist: false,
dereference: false,
});
} catch (err) {
if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") {
throw err;
}
}
const existingConfig = await readJsonObject(runtimeConfigPath);
const existingPermission = isPlainObject(existingConfig.permission)
? existingConfig.permission
: {};
const nextConfig = {
...existingConfig,
permission: {
...existingPermission,
external_directory: "allow",
},
};
await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
return {
env: {
...input.env,
XDG_CONFIG_HOME: runtimeConfigHome,
},
notes: [
"Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.",
],
cleanup: async () => {
await fs.rm(runtimeConfigHome, { recursive: true, force: true });
},
};
}

View file

@ -4,6 +4,7 @@ import type {
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asBoolean,
asString,
asStringArray,
parseObject,
@ -14,6 +15,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -90,224 +92,238 @@ export async function testEnvironment(
});
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) {
// Prevent OpenCode from writing an opencode.json into the working directory.
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
if (asBoolean(config.dangerouslySkipPermissions, true)) {
checks.push({
code: "opencode_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
code: "opencode_headless_permissions_enabled",
level: "info",
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
}
try {
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) {
checks.push({
code: "opencode_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "opencode_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
code: "opencode_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
}
}
const canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "opencode_models_discovered",
code: "opencode_command_resolvable",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
message: `Command is executable: ${command}`,
});
} else {
} catch (err) {
checks.push({
code: "opencode_models_empty",
code: "opencode_command_unresolvable",
level: "error",
message: "OpenCode returned no models.",
hint: "Run `opencode models` and verify provider authentication.",
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "error",
message: errMsg || "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
} else if (canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
const canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
} else {
checks.push({
code: "opencode_models_empty",
level: "error",
message: "OpenCode returned no models.",
hint: "Run `opencode models` and verify provider authentication.",
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "error",
message: errMsg || "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "warn",
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
} else if (canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "warn",
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
}
}
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
command,
cwd,
env: runtimeEnv,
});
checks.push({
code: "opencode_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
modelValidationPassed = true;
} catch (err) {
checks.push({
code: "opencode_model_invalid",
level: "error",
message: err instanceof Error ? err.message : "Configured model is unavailable.",
hint: "Run `opencode models` and choose a currently available provider/model ID.",
});
}
}
if (canRunProbe && modelValidationPassed) {
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const variant = asString(config.variant, "").trim();
const probeModel = configuredModel;
const args = ["run", "--format", "json"];
args.push("--model", probeModel);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
command,
cwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
});
checks.push({
code: "opencode_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
modelValidationPassed = true;
} catch (err) {
checks.push({
code: "opencode_model_invalid",
level: "error",
message: err instanceof Error ? err.message : "Configured model is unavailable.",
hint: "Run `opencode models` and choose a currently available provider/model ID.",
});
}
}
const parsed = parseOpenCodeJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
if (canRunProbe && modelValidationPassed) {
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const variant = asString(config.variant, "").trim();
const probeModel = configuredModel;
if (probe.timedOut) {
checks.push({
code: "opencode_hello_probe_timed_out",
level: "warn",
message: "OpenCode hello probe timed out.",
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "OpenCode hello probe succeeded."
: "OpenCode probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
...(detail ? { detail } : {}),
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",
level: "warn",
message: "OpenCode is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
});
} else {
const args = ["run", "--format", "json"];
args.push("--model", probeModel);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
const parsed = parseOpenCodeJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
if (probe.timedOut) {
checks.push({
code: "opencode_hello_probe_timed_out",
level: "warn",
message: "OpenCode hello probe timed out.",
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "OpenCode hello probe succeeded."
: "OpenCode probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
...(detail ? { detail } : {}),
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",
level: "warn",
message: "OpenCode is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
});
} else {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
...(detail ? { detail } : {}),
detail: err instanceof Error ? err.message : String(err),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} finally {
await preparedRuntimeConfig.cleanup();
}
return {

View file

@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0;

View file

@ -266,10 +266,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);
@ -330,8 +326,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const buildArgs = (sessionFile: string): string[] => {
const args: string[] = [];
// Use RPC mode for proper lifecycle management (waits for agent completion)
args.push("--mode", "rpc");
// Use JSON mode for structured output with print mode (non-interactive)
args.push("--mode", "json");
args.push("-p"); // Non-interactive mode: process prompt and exit
// Use --append-system-prompt to extend Pi's default system prompt
args.push("--append-system-prompt", renderedSystemPromptExtension);
@ -347,19 +344,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
args.push("--skill", PI_AGENT_SKILLS_DIR);
if (extraArgs.length > 0) args.push(...extraArgs);
// Add the user prompt as the last argument
args.push(userPrompt);
return args;
};
const buildRpcStdin = (): string => {
// Send the prompt as an RPC command
const promptCommand = {
type: "prompt",
message: userPrompt,
};
return JSON.stringify(promptCommand) + "\n";
};
const runAttempt = async (sessionFile: string) => {
const args = buildArgs(sessionFile);
if (onMeta) {
@ -406,7 +397,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
onSpawn,
onLog: bufferedOnLog,
stdin: buildRpcStdin(),
});
// Flush any remaining buffer content

View file

@ -131,7 +131,9 @@ export async function discoverPiModels(input: {
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
}
return sortModels(dedupeModels(parseModelsOutput(result.stdout)));
// Pi outputs model list to stderr, but fall back to stdout for older versions
const output = result.stderr || result.stdout;
return sortModels(dedupeModels(parseModelsOutput(output)));
}
function normalizeEnv(input: unknown): Record<string, string> {

View file

@ -17,19 +17,39 @@ function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
function extractTextContent(content: string | Array<{ type: string; text?: string; thinking?: string }>): { text: string; thinking: string } {
if (typeof content === "string") return { text: content, thinking: "" };
if (!Array.isArray(content)) return { text: "", thinking: "" };
let text = "";
let thinking = "";
for (const c of content) {
if (c.type === "text" && c.text) {
text += c.text;
}
if (c.type === "thinking" && c.thinking) {
thinking += c.thinking;
}
}
return { text, thinking };
}
// Track pending tool calls for proper toolUseId matching
let pendingToolCalls = new Map<string, { toolName: string; args: unknown }>();
export function resetParserState(): void {
pendingToolCalls.clear();
}
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
// Non-JSON line, treat as raw stdout
const trimmed = line.trim();
if (!trimmed) return [];
return [{ kind: "stdout", ts, text: trimmed }];
}
const type = asString(parsed.type);
@ -41,16 +61,64 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
// Agent lifecycle
if (type === "agent_start") {
return [{ kind: "system", ts, text: "Pi agent started" }];
return [{ kind: "system", ts, text: "🚀 Pi agent started" }];
}
if (type === "agent_end") {
return [{ kind: "system", ts, text: "Pi agent finished" }];
const entries: TranscriptEntry[] = [];
// Extract final message from messages array if available
const messages = parsed.messages as Array<Record<string, unknown>> | undefined;
if (messages && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant") {
const content = lastMessage.content as string | Array<{ type: string; text?: string; thinking?: string }>;
const { text, thinking } = extractTextContent(content);
if (thinking) {
entries.push({ kind: "thinking", ts, text: thinking });
}
if (text) {
entries.push({ kind: "assistant", ts, text });
}
// Extract usage
const usage = asRecord(lastMessage.usage);
if (usage) {
const inputTokens = (usage.inputTokens ?? usage.input ?? 0) as number;
const outputTokens = (usage.outputTokens ?? usage.output ?? 0) as number;
const cachedTokens = (usage.cacheRead ?? usage.cachedInputTokens ?? 0) as number;
const costRecord = asRecord(usage.cost);
const costUsd = (costRecord?.total ?? usage.costUsd ?? 0) as number;
if (inputTokens > 0 || outputTokens > 0) {
entries.push({
kind: "result",
ts,
text: "Run completed",
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype: "end",
isError: false,
errors: [],
});
}
}
}
}
if (entries.length === 0) {
entries.push({ kind: "system", ts, text: "✅ Pi agent finished" });
}
return entries;
}
// Turn lifecycle
if (type === "turn_start") {
return [{ kind: "system", ts, text: "Turn started" }];
return []; // Skip noisy lifecycle events
}
if (type === "turn_end") {
@ -60,16 +128,21 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>;
const { text, thinking } = extractTextContent(content);
if (thinking) {
entries.push({ kind: "thinking", ts, text: thinking });
}
if (text) {
entries.push({ kind: "assistant", ts, text });
}
}
// Process tool results
// Process tool results - match with pending tool calls
if (toolResults) {
for (const tr of toolResults) {
const toolCallId = asString(tr.toolCallId, `tool-${Date.now()}`);
const content = tr.content;
const isError = tr.isError === true;
@ -78,23 +151,31 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
if (typeof content === "string") {
contentStr = content;
} else if (Array.isArray(content)) {
contentStr = extractTextContent(content as Array<{ type: string; text?: string }>);
const extracted = extractTextContent(content as Array<{ type: string; text?: string }>);
contentStr = extracted.text || JSON.stringify(content);
} else {
contentStr = JSON.stringify(content);
}
// Get tool name from pending calls if available
const pendingCall = pendingToolCalls.get(toolCallId);
const toolName = asString(tr.toolName, pendingCall?.toolName || "tool");
entries.push({
kind: "tool_result",
ts,
toolUseId: asString(tr.toolCallId, "unknown"),
toolName: asString(tr.toolName),
toolUseId: toolCallId,
toolName,
content: contentStr,
isError,
});
// Clean up pending call
pendingToolCalls.delete(toolCallId);
}
}
return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }];
return entries;
}
// Message streaming
@ -106,33 +187,81 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const assistantEvent = asRecord(parsed.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type);
// Handle thinking deltas
if (msgType === "thinking_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
return [{ kind: "thinking", ts, text: delta, delta: true }];
}
}
// Handle text deltas
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
}
// Handle thinking end - emit full thinking block
if (msgType === "thinking_end") {
const content = asString(assistantEvent.content);
if (content) {
return [{ kind: "thinking", ts, text: content }];
}
}
// Handle text end - emit full text block
if (msgType === "text_end") {
const content = asString(assistantEvent.content);
if (content) {
return [{ kind: "assistant", ts, text: content }];
}
}
}
return [];
}
if (type === "message_end") {
const message = asRecord(parsed.message);
if (message) {
const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>;
const { text, thinking } = extractTextContent(content);
const entries: TranscriptEntry[] = [];
// Emit final thinking block if present
if (thinking) {
entries.push({ kind: "thinking", ts, text: thinking });
}
// Emit final text block if present
if (text) {
entries.push({ kind: "assistant", ts, text });
}
return entries;
}
return [];
}
// Tool execution
if (type === "tool_execution_start") {
const toolName = asString(parsed.toolName);
const toolCallId = asString(parsed.toolCallId, `tool-${Date.now()}`);
const toolName = asString(parsed.toolName, "tool");
const args = parsed.args;
if (toolName) {
return [{
kind: "tool_call",
ts,
name: toolName,
input: args,
}];
}
return [{ kind: "system", ts, text: `Tool started` }];
// Track this tool call for later matching
pendingToolCalls.set(toolCallId, { toolName, args });
return [{
kind: "tool_call",
ts,
name: toolName,
input: args,
toolUseId: toolCallId,
}];
}
if (type === "tool_execution_update") {
@ -140,40 +269,43 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
}
if (type === "tool_execution_end") {
const toolCallId = asString(parsed.toolCallId);
const toolName = asString(parsed.toolName);
const toolCallId = asString(parsed.toolCallId, `tool-${Date.now()}`);
const toolName = asString(parsed.toolName, "tool");
const result = parsed.result;
const isError = parsed.isError === true;
// Extract text from Pi's content array format
// Can be: {"content": [{"type": "text", "text": "..."}]} or [{"type": "text", "text": "..."}]
let contentStr: string;
if (typeof result === "string") {
contentStr = result;
} else if (Array.isArray(result)) {
// Direct array format: result is [{"type": "text", "text": "..."}]
contentStr = extractTextContent(result as Array<{ type: string; text?: string }>);
const extracted = extractTextContent(result as Array<{ type: string; text?: string }>);
contentStr = extracted.text || JSON.stringify(result);
} else if (result && typeof result === "object") {
const resultObj = result as Record<string, unknown>;
if (Array.isArray(resultObj.content)) {
// Wrapped format: result is {"content": [{"type": "text", "text": "..."}]}
contentStr = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
const extracted = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
contentStr = extracted.text || JSON.stringify(result);
} else {
contentStr = JSON.stringify(result);
}
} else {
contentStr = JSON.stringify(result);
contentStr = String(result);
}
// Clean up pending call
pendingToolCalls.delete(toolCallId);
return [{
kind: "tool_result",
ts,
toolUseId: toolCallId || "unknown",
toolUseId: toolCallId,
toolName,
content: contentStr,
isError,
}];
}
// Fallback for unknown event types
return [{ kind: "stdout", ts, text: line }];
}

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

@ -1,83 +1,24 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
applyPendingMigrations,
ensurePostgresDatabase,
inspectMigrations,
} from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
const tempPaths: string[] = [];
const runningInstances: EmbeddedPostgresInstance[] = [];
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createTempDatabase(): Promise<string> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-"));
tempPaths.push(dataDir);
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
runningInstances.push(instance);
const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminUrl, "paperclip");
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-");
cleanups.push(db.cleanup);
return db.connectionString;
}
async function migrationHash(migrationFile: string): Promise<string> {
@ -89,19 +30,19 @@ async function migrationHash(migrationFile: string): Promise<string> {
}
afterEach(async () => {
while (runningInstances.length > 0) {
const instance = runningInstances.pop();
if (!instance) continue;
await instance.stop();
}
while (tempPaths.length > 0) {
const tempPath = tempPaths.pop();
if (!tempPath) continue;
fs.rmSync(tempPath, { recursive: true, force: true });
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
await cleanup?.();
}
});
describe("applyPendingMigrations", () => {
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("applyPendingMigrations", () => {
it(
"applies an inserted earlier migration without replaying later legacy migrations",
async () => {

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
describe("formatEmbeddedPostgresError", () => {
it("adds a shared-memory hint when initdb logs expose the real cause", () => {
const error = formatEmbeddedPostgresError("Postgres init script exited with code 1.", {
fallbackMessage: "Failed to initialize embedded PostgreSQL cluster",
recentLogs: [
"running bootstrap script ...",
"FATAL: could not create shared memory segment: Cannot allocate memory",
"DETAIL: Failed system call was shmget(key=123, size=56, 03600).",
],
});
expect(error.message).toContain("could not allocate shared memory");
expect(error.message).toContain("kern.sysv.shm");
expect(error.message).toContain("could not create shared memory segment");
});
it("keeps only recent non-empty log lines in the collector", () => {
const buffer = createEmbeddedPostgresLogBuffer(2);
buffer.append("line one\n\n");
buffer.append("line two");
buffer.append("line three");
expect(buffer.getRecentLogs()).toEqual(["line two", "line three"]);
});
});

View file

@ -0,0 +1,89 @@
const DEFAULT_RECENT_LOG_LIMIT = 40;
const RECENT_LOG_SUMMARY_LINES = 8;
function toError(error: unknown, fallbackMessage: string): Error {
if (error instanceof Error) return error;
if (error === undefined) return new Error(fallbackMessage);
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
try {
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
} catch {
return new Error(`${fallbackMessage}: ${String(error)}`);
}
}
function summarizeRecentLogs(recentLogs: string[]): string | null {
if (recentLogs.length === 0) return null;
return recentLogs
.slice(-RECENT_LOG_SUMMARY_LINES)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join(" | ");
}
function detectEmbeddedPostgresHint(recentLogs: string[]): string | null {
const haystack = recentLogs.join("\n").toLowerCase();
if (!haystack.includes("could not create shared memory segment")) {
return null;
}
return (
"Embedded PostgreSQL bootstrap could not allocate shared memory. " +
"On macOS, this usually means the host's kern.sysv.shm* limits are too low for another local PostgreSQL cluster. " +
"Stop other local PostgreSQL servers or raise the shared-memory sysctls, then retry."
);
}
export function createEmbeddedPostgresLogBuffer(limit = DEFAULT_RECENT_LOG_LIMIT): {
append(message: unknown): void;
getRecentLogs(): string[];
} {
const recentLogs: string[] = [];
return {
append(message: unknown) {
const text =
typeof message === "string"
? message
: message instanceof Error
? message.message
: String(message ?? "");
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
recentLogs.push(line);
if (recentLogs.length > limit) {
recentLogs.splice(0, recentLogs.length - limit);
}
}
},
getRecentLogs() {
return [...recentLogs];
},
};
}
export function formatEmbeddedPostgresError(
error: unknown,
input: {
fallbackMessage: string;
recentLogs?: string[];
},
): Error {
const baseError = toError(error, input.fallbackMessage);
const recentLogs = input.recentLogs ?? [];
const parts = [baseError.message];
const hint = detectEmbeddedPostgresHint(recentLogs);
const recentSummary = summarizeRecentLogs(recentLogs);
if (hint) {
parts.push(hint);
}
if (recentSummary) {
parts.push(`Recent embedded Postgres logs: ${recentSummary}`);
}
return new Error(parts.join(" "));
}

View file

@ -11,6 +11,12 @@ export {
type MigrationBootstrapResult,
type Db,
} from "./client.js";
export {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestSupport,
} from "./test-embedded-postgres.js";
export {
runDatabaseBackup,
runDatabaseRestore,
@ -19,4 +25,8 @@ export {
type RunDatabaseBackupResult,
type RunDatabaseRestoreOptions,
} from "./backup-lib.js";
export {
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
} from "./embedded-postgres-error.js";
export * from "./schema/index.js";

View file

@ -2,6 +2,7 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import path from "node:path";
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
@ -27,18 +28,6 @@ export type MigrationConnection = {
stop: () => Promise<void>;
};
function toError(error: unknown, fallbackMessage: string): Error {
if (error instanceof Error) return error;
if (error === undefined) return new Error(fallbackMessage);
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
try {
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
} catch {
return new Error(`${fallbackMessage}: ${String(error)}`);
}
}
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
@ -109,6 +98,7 @@ async function ensureEmbeddedPostgresConnection(
const runningPid = readRunningPostmasterPid(postmasterPidFile);
const runningPort = readPidFilePort(postmasterPidFile);
const preferredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
const logBuffer = createEmbeddedPostgresLogBuffer();
if (!runningPid && existsSync(pgVersionFile)) {
try {
@ -150,19 +140,20 @@ async function ensureEmbeddedPostgresConnection(
password: "paperclip",
port: selectedPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: logBuffer.append,
onError: logBuffer.append,
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
try {
await instance.initialise();
} catch (error) {
throw toError(
error,
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
);
throw formatEmbeddedPostgresError(error, {
fallbackMessage:
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
}
if (existsSync(postmasterPidFile)) {
@ -171,7 +162,10 @@ async function ensureEmbeddedPostgresConnection(
try {
await instance.start();
} catch (error) {
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
throw formatEmbeddedPostgresError(error, {
fallbackMessage: `Failed to start embedded PostgreSQL on port ${selectedPort}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;

View file

@ -0,0 +1,17 @@
CREATE TABLE "issue_inbox_archives" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"user_id" text NOT NULL,
"archived_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP INDEX "board_api_keys_key_hash_idx";--> statement-breakpoint
ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_inbox_archives_company_issue_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id");--> statement-breakpoint
CREATE INDEX "issue_inbox_archives_company_user_idx" ON "issue_inbox_archives" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_inbox_archives_company_issue_user_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash");

File diff suppressed because it is too large Load diff

View file

@ -316,6 +316,13 @@
"when": 1774269579794,
"tag": "0044_illegal_toad",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1774530504348,
"tag": "0045_workable_shockwave",
"breakpoints": true
}
]
}

View file

@ -31,6 +31,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const issueInboxArchives = pgTable(
"issue_inbox_archives",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
userId: text("user_id").notNull(),
archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("issue_inbox_archives_company_issue_idx").on(table.companyId, table.issueId),
companyUserIdx: index("issue_inbox_archives_company_user_idx").on(table.companyId, table.userId),
companyIssueUserUnique: uniqueIndex("issue_inbox_archives_company_issue_user_idx").on(
table.companyId,
table.issueId,
table.userId,
),
}),
);

View file

@ -0,0 +1,144 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
export type EmbeddedPostgresTestSupport = {
supported: boolean;
reason?: string;
};
export type EmbeddedPostgresTestDatabase = {
connectionString: string;
cleanup(): Promise<void>;
};
let embeddedPostgresSupportPromise: Promise<EmbeddedPostgresTestSupport> | null = null;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
function formatEmbeddedPostgresError(error: unknown): string {
if (error instanceof Error && error.message.length > 0) return error.message;
if (typeof error === "string" && error.length > 0) return error;
return "embedded Postgres startup failed";
}
async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});
try {
await instance.initialise();
await instance.start();
return { supported: true };
} catch (error) {
return {
supported: false,
reason: formatEmbeddedPostgresError(error),
};
} finally {
await instance.stop().catch(() => {});
fs.rmSync(dataDir, { recursive: true, force: true });
}
}
export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgresTestSupport> {
if (!embeddedPostgresSupportPromise) {
embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport();
}
return await embeddedPostgresSupportPromise;
}
export async function startEmbeddedPostgresTestDatabase(
tempDirPrefix: string,
): Promise<EmbeddedPostgresTestDatabase> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});
try {
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return {
connectionString,
cleanup: async () => {
await instance.stop().catch(() => {});
fs.rmSync(dataDir, { recursive: true, force: true });
},
};
} catch (error) {
await instance.stop().catch(() => {});
fs.rmSync(dataDir, { recursive: true, force: true });
throw new Error(
`Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`,
);
}
}

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",

View file

@ -0,0 +1,30 @@
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -23,7 +23,7 @@
* for a particular string, we need to force that string into the right locale.
* @see https://github.com/leinelissen/embedded-postgres/issues/15
*/
-const LC_MESSAGES_LOCALE = 'en_US.UTF-8';
+const LC_MESSAGES_LOCALE = 'C';
// The default configuration options for the class
const defaults = {
databaseDir: path.join(process.cwd(), 'data', 'db'),
@@ -133,7 +133,7 @@
`--pwfile=${passwordFile}`,
`--lc-messages=${LC_MESSAGES_LOCALE}`,
...this.options.initdbFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
@@ -177,7 +177,7 @@
'-p',
this.options.port.toString(),
...this.options.postgresFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it

25
pnpm-lock.yaml generated
View file

@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
embedded-postgres@18.1.0-beta.16:
hash: 55uhvnotpqyiy37rn3pqpukhei
path: patches/embedded-postgres@18.1.0-beta.16.patch
importers:
.:
@ -53,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
@ -73,7 +81,7 @@ importers:
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)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
picocolors:
specifier: ^1.1.1
version: 1.1.1
@ -215,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':
@ -225,7 +239,7 @@ importers:
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)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
postgres:
specifier: ^3.4.5
version: 3.4.8
@ -494,7 +508,7 @@ importers:
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)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
express:
specifier: ^5.1.0
version: 5.2.1
@ -613,6 +627,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
@ -9973,7 +9990,7 @@ snapshots:
electron-to-chromium@1.5.286: {}
embedded-postgres@18.1.0-beta.16:
embedded-postgres@18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei):
dependencies:
async-exit-hook: 2.0.1
pg: 8.18.0

77
releases/v2026.325.0.md Normal file
View file

@ -0,0 +1,77 @@
# v2026.325.0
> Released: 2026-03-25
## Highlights
- **Company import/export** — Full company portability with a file-browser UX for importing and exporting agent companies. Includes rich frontmatter preview, nested file picker, merge-history support, GitHub shorthand refs, and CLI `company import`/`company export` commands. Imported companies open automatically after import, and heartbeat timers are disabled for imported agents by default. ([#840](https://github.com/paperclipai/paperclip/pull/840), [#1631](https://github.com/paperclipai/paperclip/pull/1631), [#1632](https://github.com/paperclipai/paperclip/pull/1632), [#1655](https://github.com/paperclipai/paperclip/pull/1655))
- **Company skills library** — New company-scoped skills system with a skills UI, agent skill sync across all local adapters (Claude, Codex, Pi, Gemini), pinned GitHub skills with update checks, and built-in skill support. ([#1346](https://github.com/paperclipai/paperclip/pull/1346))
- **Routines and recurring tasks** — Full routines engine with triggers, routine runs, coalescing, and recurring task portability. Includes API documentation and routine export support. ([#1351](https://github.com/paperclipai/paperclip/pull/1351), [#1622](https://github.com/paperclipai/paperclip/pull/1622), @aronprins)
## Improvements
- **Inline join requests in inbox** — Join requests now render inline in the inbox alongside approvals and other work items.
- **Onboarding seeding** — New projects and issues are seeded with goal context during onboarding for a better first-run experience.
- **Agent instructions recovery** — Managed agent instructions are recovered from disk on startup; instructions are preserved across adapter switches.
- **Heartbeats settings page** — Shows all agents regardless of interval config; added a "Disable All" button for quick bulk control.
- **Agent history via participation** — Agent issue history now uses participation records instead of direct assignment lookups.
- **Alphabetical agent sorting** — Agents are sorted alphabetically by name across all views.
- **Company org chart assets** — Improved generated org chart visuals for companies.
- **Improved CLI API connection errors** — Better error messages when the CLI cannot reach the Paperclip API.
- **Markdown mention links** — Custom URL schemes are now allowed in Lexical LinkNode, enabling mention pills with proper linking behavior. Atomic deletion of mention pills works correctly.
- **Issue workspace reuse** — Workspaces are correctly reused after isolation runs.
- **Failed-run session resume** — Explicit failed-run sessions can now be resumed via honor flag.
- **Docker image CI** — Added Docker image build and deploy workflow. ([#542](https://github.com/paperclipai/paperclip/pull/542), @albttx)
- **Project filter on issues** — Issues list can now be filtered by project. ([#552](https://github.com/paperclipai/paperclip/pull/552), @mvanhorn)
- **Inline comment image attachments** — Uploaded images are now embedded inline in comments. ([#551](https://github.com/paperclipai/paperclip/pull/551), @mvanhorn)
- **AGENTS.md fallback** — Claude-local adapter gracefully falls back when AGENTS.md is missing. ([#550](https://github.com/paperclipai/paperclip/pull/550), @mvanhorn)
- **Company-creator skill** — New skill for scaffolding agent company packages from scratch.
- **Reports page rename** — Reports section renamed for clarity. ([#1380](https://github.com/paperclipai/paperclip/pull/1380), @DanielSousa)
- **Eval framework bootstrap** — Promptfoo-based evaluation framework with YAML test cases for systematic agent behavior testing. ([#832](https://github.com/paperclipai/paperclip/pull/832), @mvanhorn)
- **Board CLI authentication** — Browser-based auth flow for the CLI so board users can authenticate without manually copying API keys. ([#1635](https://github.com/paperclipai/paperclip/pull/1635))
## Fixes
- **Embedded Postgres initdb in Docker slim** — Fixed initdb failure in slim containers by adding proper initdbFlags types. ([#737](https://github.com/paperclipai/paperclip/pull/737), @alaa-alghazouli)
- **OpenClaw gateway crash** — Fixed unhandled rejection when challengePromise fails. ([#743](https://github.com/paperclipai/paperclip/pull/743), @Sigmabrogz)
- **Agent mention pill alignment** — Fixed vertical misalignment between agent mention pills and project mention pills.
- **Task assignment grants** — Preserved task assignment grants for agents that have already joined.
- **Instructions tab state** — Fixed tab state not updating correctly when switching between agents.
- **Imported agent bundle frontmatter** — Fixed frontmatter leakage in imported agent bundles.
- **Login form 1Password detection** — Fixed login form not being detected by password managers; Enter key now submits correctly. ([#1014](https://github.com/paperclipai/paperclip/pull/1014))
- **Pill contrast (WCAG)** — Improved mention pill contrast using WCAG contrast ratios on composited backgrounds.
- **Documents horizontal scroll** — Prevented documents row from causing horizontal scroll on mobile.
- **Toggle switch sizing** — Fixed oversized toggle switches on mobile; added missing `data-slot` attributes.
- **Agent instructions tab responsive** — Made agent instructions tab responsive on mobile.
- **Monospace font sizing** — Adjusted inline code font size and added dark mode background.
- **Priority icon removal** — Removed priority icon from issue rows for a cleaner list view.
- **Same-page issue toasts** — Suppressed redundant toasts when navigating to an issue already on screen.
- **Noisy adapter log** — Removed noisy "Loaded agent instructions file" log message from all adapters.
- **Pi local adapter** — Fixed Pi adapter missing from `isLocal` check. ([#1382](https://github.com/paperclipai/paperclip/pull/1382), @lucas-stellet)
- **CLI auth migration idempotency** — Made migration 0044 idempotent to avoid failures on re-run.
- **Dev restart tracking**`.paperclip` and test-only paths are now ignored in dev restart detection.
- **Duplicate CLI auth flag** — Fixed duplicate `--company` flag on `auth login`.
- **Gemini local execution** — Fixed Gemini local adapter execution and diagnostics.
- **Sidebar ordering** — Preserved sidebar ordering during company portability operations.
- **Company skill deduplication** — Fixed duplicate skill inventory refreshes.
- **Worktree merge-history migrations** — Fixed migration handling in worktree contexts. ([#1385](https://github.com/paperclipai/paperclip/pull/1385))
## Upgrade Guide
Seven new database migrations (`0038``0044`) will run automatically on startup:
- **Migration 0038** adds process tracking columns to heartbeat runs (PID, started-at, retry tracking).
- **Migration 0039** adds the routines engine tables (routines, triggers, routine runs).
- **Migrations 00400042** extend company skills, recurring tasks, and portability metadata.
- **Migration 0043** adds the Codex managed-home and agent instructions recovery columns.
- **Migration 0044** adds board API keys and CLI auth challenge tables for browser-based CLI auth.
All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
If you use the company import/export feature, note that imported companies have heartbeat timers disabled by default. Re-enable them manually from the Heartbeats settings page after verifying adapter configuration.
## Contributors
Thank you to everyone who contributed to this release!
@alaa-alghazouli, @albttx, @AOrobator, @aronprins, @cryppadotta, @DanielSousa, @lucas-stellet, @mvanhorn, @richardanaya, @Sigmabrogz

View file

@ -0,0 +1,31 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const uiDir = join(repoRoot, "ui");
const packageJsonPath = join(uiDir, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const publishPackageJson = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
license: packageJson.license,
homepage: packageJson.homepage,
bugs: packageJson.bugs,
repository: packageJson.repository,
type: packageJson.type,
files: ["dist"],
publishConfig: {
access: "public",
},
};
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
console.log(" ✓ Generated publishable UI package.json");

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

@ -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

@ -3,6 +3,12 @@ set -euo pipefail
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
paperclip_dir="$worktree_cwd/.paperclip"
worktree_config_path="$paperclip_dir/config.json"
worktree_env_path="$paperclip_dir/.env"
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
if [[ ! -d "$base_cwd" ]]; then
echo "Base workspace does not exist: $base_cwd" >&2
@ -14,6 +20,286 @@ if [[ ! -d "$worktree_cwd" ]]; then
exit 1
fi
source_config_path="${PAPERCLIP_CONFIG:-}"
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
source_config_path="$base_cwd/.paperclip/config.json"
fi
if [[ -z "$source_config_path" ]]; then
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
fi
source_env_path="$(dirname "$source_config_path")/.env"
mkdir -p "$paperclip_dir"
run_isolated_worktree_init() {
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
return 0
fi
if command -v paperclipai >/dev/null 2>&1; then
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
return 0
fi
return 1
}
write_fallback_worktree_config() {
WORKTREE_NAME="$worktree_name" \
BASE_CWD="$base_cwd" \
WORKTREE_CWD="$worktree_cwd" \
PAPERCLIP_DIR="$paperclip_dir" \
SOURCE_CONFIG_PATH="$source_config_path" \
SOURCE_ENV_PATH="$source_env_path" \
PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \
node <<'EOF'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
function expandHomePrefix(value) {
if (!value) return value;
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function nonEmpty(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function sanitizeInstanceId(value) {
const trimmed = String(value ?? "").trim().toLowerCase();
const normalized = trimmed
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
return normalized || "worktree";
}
function parseEnvFile(contents) {
const entries = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (!value) {
entries[key] = "";
continue;
}
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
async function findAvailablePort(preferredPort, reserved = new Set()) {
const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0;
if (startPort > 0) {
for (let port = startPort; port < startPort + 100; port += 1) {
if (reserved.has(port)) continue;
const available = await new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
if (available) return port;
}
}
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate a port.")));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
function isLoopbackHost(hostname) {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
function rewriteLocalUrlPort(rawUrl, port) {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
return rawUrl;
}
}
function resolveRuntimeLikePath(value, configPath) {
const expanded = expandHomePrefix(value);
if (path.isAbsolute(expanded)) return expanded;
return path.resolve(path.dirname(configPath), expanded);
}
async function main() {
const worktreeName = process.env.WORKTREE_NAME;
const paperclipDir = process.env.PAPERCLIP_DIR;
const sourceConfigPath = process.env.SOURCE_CONFIG_PATH;
const sourceEnvPath = process.env.SOURCE_ENV_PATH;
const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees"));
const instanceId = sanitizeInstanceId(worktreeName);
const instanceRoot = path.resolve(worktreeHome, "instances", instanceId);
const configPath = path.resolve(paperclipDir, "config.json");
const envPath = path.resolve(paperclipDir, ".env");
let sourceConfig = null;
if (sourceConfigPath && fs.existsSync(sourceConfigPath)) {
sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8"));
}
const sourceEnvEntries =
sourceEnvPath && fs.existsSync(sourceEnvPath)
? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8"))
: {};
const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1;
const serverPort = await findAvailablePort(preferredServerPort);
const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1;
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
fs.rmSync(configPath, { force: true });
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(instanceRoot, { recursive: true });
const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort);
const targetConfig = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "configure",
},
...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
embeddedPostgresPort: databasePort,
backup: {
enabled: sourceConfig?.database?.backup?.enabled ?? true,
intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60,
retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30,
dir: path.resolve(instanceRoot, "data", "backups"),
},
},
logging: {
mode: sourceConfig?.logging?.mode ?? "file",
logDir: path.resolve(instanceRoot, "logs"),
},
server: {
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
exposure: sourceConfig?.server?.exposure ?? "private",
host: sourceConfig?.server?.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
serveUi: sourceConfig?.server?.serveUi ?? true,
},
auth: {
baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto",
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
disableSignUp: sourceConfig?.auth?.disableSignUp ?? false,
},
storage: {
provider: sourceConfig?.storage?.provider ?? "local_disk",
localDisk: {
baseDir: path.resolve(instanceRoot, "data", "storage"),
},
s3: {
bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip",
region: sourceConfig?.storage?.s3?.region ?? "us-east-1",
endpoint: sourceConfig?.storage?.s3?.endpoint,
prefix: sourceConfig?.storage?.s3?.prefix ?? "",
forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false,
},
},
secrets: {
provider: sourceConfig?.secrets?.provider ?? "local_encrypted",
strictMode: sourceConfig?.secrets?.strictMode ?? false,
localEncrypted: {
keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
},
},
};
fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 });
const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY);
if (inlineMasterKey) {
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, {
encoding: "utf8",
mode: 0o600,
});
} else {
const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE)
? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath)
: nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath)
? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath)
: null;
if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) {
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath);
fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600);
}
}
const envLines = [
"PAPERCLIP_HOME=" + JSON.stringify(worktreeHome),
"PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId),
"PAPERCLIP_CONFIG=" + JSON.stringify(configPath),
"PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")),
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName),
];
const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET);
if (agentJwtSecret) {
envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret));
}
fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 });
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
EOF
}
if ! run_isolated_worktree_init; then
echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2
write_fallback_worktree_config
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
source_path="$base_cwd/$relative_path"

View file

@ -33,7 +33,7 @@
],
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"dev:watch": "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/",
"prepack": "pnpm run prepare:ui-dist",

View file

@ -0,0 +1,33 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
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 serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
const child = spawn(
process.execPath,
[tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"],
{
cwd: serverRoot,
env: process.env,
stdio: "inherit",
},
);
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
child.on("error", (error) => {
console.error(error);
process.exit(1);
});

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -7,6 +7,12 @@ import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
const itWindows = process.platform === "win32" ? it : it.skip;
describe("codex_local environment diagnostics", () => {
beforeEach(() => {
vi.stubEnv("OPENAI_API_KEY", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
@ -32,6 +38,67 @@ describe("codex_local environment diagnostics", () => {
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
it("emits codex_native_auth_present when ~/.codex/auth.json exists and OPENAI_API_KEY is unset", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const codexHome = path.join(root, ".codex");
const cwd = path.join(root, "workspace");
try {
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(
path.join(codexHome, "auth.json"),
JSON.stringify({ accessToken: "fake-token", accountId: "acct-1" }),
);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: process.execPath,
cwd,
env: { CODEX_HOME: codexHome },
},
});
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(true);
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits codex_openai_api_key_missing when neither env var nor native auth exists", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const codexHome = path.join(root, ".codex");
const cwd = path.join(root, "workspace");
try {
await fs.mkdir(codexHome, { recursive: true });
// No auth.json written
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: process.execPath,
cwd,
env: { CODEX_HOME: codexHome },
},
});
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(true);
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
const root = path.join(
os.tmpdir(),

View file

@ -210,7 +210,7 @@ describe("codex execute", () => {
"company-1",
"codex-home",
);
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip");
const homeSkill = path.join(isolatedCodexHome, "skills", "paperclip");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
@ -284,7 +284,7 @@ describe("codex execute", () => {
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true);
expect((await fs.lstat(homeSkill)).isSymbolicLink()).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
@ -371,7 +371,7 @@ describe("codex execute", () => {
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(explicitCodexHome);
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
expect((await fs.lstat(path.join(explicitCodexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.HOME;

View file

@ -43,7 +43,7 @@ describe("codex local skill sync", () => {
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills");
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/");
});
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -28,6 +28,13 @@ console.log(JSON.stringify({
}
describe("cursor environment diagnostics", () => {
beforeEach(() => {
vi.stubEnv("CURSOR_API_KEY", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
@ -116,4 +123,73 @@ describe("cursor environment diagnostics", () => {
expect(args).not.toContain("--trust");
await fs.rm(root, { recursive: true, force: true });
});
it("emits cursor_native_auth_present when cli-config.json has authInfo and CURSOR_API_KEY is unset", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-cursor-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const cursorHome = path.join(root, ".cursor");
const cwd = path.join(root, "workspace");
try {
await fs.mkdir(cursorHome, { recursive: true });
await fs.writeFile(
path.join(cursorHome, "cli-config.json"),
JSON.stringify({
authInfo: {
email: "test@example.com",
displayName: "Test User",
userId: 12345,
},
}),
);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "cursor",
config: {
command: process.execPath,
cwd,
env: { CURSOR_HOME: cursorHome },
},
});
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(true);
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(false);
const authCheck = result.checks.find((check) => check.code === "cursor_native_auth_present");
expect(authCheck?.detail).toContain("test@example.com");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits cursor_api_key_missing when neither env var nor native auth exists", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-cursor-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const cursorHome = path.join(root, ".cursor");
const cwd = path.join(root, "workspace");
try {
await fs.mkdir(cursorHome, { recursive: true });
// No cli-config.json written
const result = await testEnvironment({
companyId: "company-1",
adapterType: "cursor",
config: {
command: process.execPath,
cwd,
env: { CURSOR_HOME: cursorHome },
},
});
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(true);
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js";
describe("resolveServerDevWatchIgnorePaths", () => {
it("includes both the worktree UI paths and their real shared targets", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-"));
const sharedUiRoot = path.join(tempRoot, "shared-ui");
const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884");
const serverRoot = path.join(worktreeRoot, "server");
const worktreeUiRoot = path.join(worktreeRoot, "ui");
fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true });
fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true });
fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true });
fs.mkdirSync(serverRoot, { recursive: true });
fs.mkdirSync(worktreeUiRoot, { recursive: true });
fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules"));
fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite"));
fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist"));
const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot);
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules"));
expect(ignorePaths).toContain(`${path.join(worktreeUiRoot, "node_modules").replaceAll(path.sep, "/")}/**`);
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules")));
expect(ignorePaths).toContain(`${fs.realpathSync(path.join(sharedUiRoot, "node_modules")).replaceAll(path.sep, "/")}/**`);
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules", ".vite-temp"));
expect(ignorePaths).toContain(
`${path.join(worktreeUiRoot, "node_modules", ".vite-temp").replaceAll(path.sep, "/")}/**`,
);
expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite"));
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite")));
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist"));
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist")));
expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**");
expect(ignorePaths).toContain("**/.vite-temp/**");
});
});

View file

@ -1,89 +1,29 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
applyPendingMigrations,
createDb,
ensurePostgresDatabase,
agents,
agentWakeupRequests,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { runningProcesses } from "../adapters/index.ts";
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, instance, dataDir };
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function spawnAliveProcess() {
@ -92,17 +32,14 @@ function spawnAliveProcess() {
});
}
describe("heartbeat orphaned process recovery", () => {
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const childProcesses = new Set<ChildProcess>();
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => {
}
childProcesses.clear();
runningProcesses.clear();
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
await tempDb?.cleanup();
});
async function seedRunFixture(input?: {

View file

@ -55,7 +55,7 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const fallbackCwd = resolveDefaultAgentWorkspaceDir({ id: agentId });
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
@ -96,7 +96,7 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const fallbackCwd = resolveDefaultAgentWorkspaceDir({ id: agentId });
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,

View file

@ -0,0 +1,6 @@
export {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestSupport,
} from "@paperclipai/db";

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
describe("agentJoinGrantsFromDefaults", () => {
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
expect(agentJoinGrantsFromDefaults(null)).toEqual([
{
permissionKey: "tasks:assign",
scope: null,
},
]);
});
it("preserves invite agent grants and appends tasks:assign", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "agents:create",
scope: null,
},
],
},
}),
).toEqual([
{
permissionKey: "agents:create",
scope: null,
},
{
permissionKey: "tasks:assign",
scope: null,
},
]);
});
it("does not duplicate tasks:assign when invite defaults already include it", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
],
},
}),
).toEqual([
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
]);
});
});

View file

@ -20,16 +20,29 @@ describe("issue goal fallback", () => {
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
projectGoalId: "goal-3",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
it("inherits the project goal when creating a project-linked issue", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
projectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the project has no goal", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
projectGoalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
@ -40,20 +53,47 @@ describe("issue goal fallback", () => {
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
currentProjectGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
it("switches from the company fallback to the project goal when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
currentProjectGoalId: null,
projectId: "project-1",
goalId: null,
projectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBeNull();
).toBe("goal-2");
});
it("backfills the project goal for legacy project-linked issues on update", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: "project-1",
currentGoalId: null,
currentProjectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("preserves an explicit goal across project fallback changes", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: "project-1",
currentGoalId: "goal-explicit",
currentProjectGoalId: "goal-2",
projectId: "project-2",
projectGoalId: "goal-3",
defaultGoalId: "goal-1",
}),
).toBe("goal-explicit");
});
});

View file

@ -0,0 +1,187 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getAncestors: vi.fn(),
findMentionedProjectIds: vi.fn(),
getCommentCursor: vi.fn(),
getComment: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
listByIds: vi.fn(),
}));
const mockGoalService = vi.hoisted(() => ({
getById: vi.fn(),
getDefaultCompanyGoal: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
const legacyProjectLinkedIssue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-581",
title: "Legacy onboarding task",
description: "Seed the first CEO task",
status: "todo",
priority: "medium",
projectId: "22222222-2222-4222-8222-222222222222",
goalId: null,
parentId: null,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
updatedAt: new Date("2026-03-24T12:00:00Z"),
executionWorkspaceId: null,
labels: [],
labelIds: [],
};
const projectGoal = {
id: "44444444-4444-4444-8444-444444444444",
companyId: "company-1",
title: "Launch the company",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
};
describe("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
mockIssueService.getCommentCursor.mockResolvedValue({
totalComments: 0,
latestCommentId: null,
latestCommentAt: null,
});
mockIssueService.getComment.mockResolvedValue(null);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
urlKey: "onboarding",
goalId: projectGoal.id,
goalIds: [projectGoal.id],
goals: [{ id: projectGoal.id, title: projectGoal.title }],
name: "Onboarding",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/company-1/project-1",
effectiveLocalFolder: "/tmp/company-1/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
});
mockProjectService.listByIds.mockResolvedValue([]);
mockGoalService.getById.mockImplementation(async (id: string) =>
id === projectGoal.id ? projectGoal : null,
);
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
});
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
expect(res.status).toBe(200);
expect(res.body.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
const res = await request(createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(res.body.issue.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
});

View file

@ -1,103 +1,43 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
applyPendingMigrations,
companies,
createDb,
ensurePostgresDatabase,
issueComments,
issueInboxArchives,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("issueService.list participantAgentId", () => {
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(agents);
@ -105,10 +45,7 @@ describe("issueService.list participantAgentId", () => {
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
await tempDb?.cleanup();
});
it("returns issues an agent participated in across the supported signals", async () => {
@ -281,4 +218,99 @@ describe("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const visibleIssueId = randomUUID();
const archivedIssueId = randomUUID();
const resurfacedIssueId = randomUUID();
await db.insert(issues).values([
{
id: visibleIssueId,
companyId,
title: "Visible issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: archivedIssueId,
companyId,
title: "Archived issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
},
{
id: resurfacedIssueId,
companyId,
title: "Resurfaced issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
await svc.archiveInbox(
companyId,
archivedIssueId,
userId,
new Date("2026-03-26T12:30:00.000Z"),
);
await svc.archiveInbox(
companyId,
resurfacedIssueId,
userId,
new Date("2026-03-26T13:00:00.000Z"),
);
await db.insert(issueComments).values({
companyId,
issueId: resurfacedIssueId,
authorUserId: otherUserId,
body: "This should bring the issue back into Mine.",
createdAt: new Date("2026-03-26T13:30:00.000Z"),
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
});
const archivedFiltered = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
resurfacedIssueId,
visibleIssueId,
]);
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
const afterUnarchive = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
visibleIssueId,
archivedIssueId,
resurfacedIssueId,
]));
});
});

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { normalizeAgentMentionToken } from "../services/issues.ts";
describe("normalizeAgentMentionToken", () => {
it("decodes hex numeric entities such as space (&#x20;)", () => {
expect(normalizeAgentMentionToken("Baba&#x20;")).toBe("Baba");
});
it("decodes decimal numeric entities", () => {
expect(normalizeAgentMentionToken("Baba&#32;")).toBe("Baba");
});
it("decodes common named whitespace entities", () => {
expect(normalizeAgentMentionToken("Baba&nbsp;")).toBe("Baba");
});
// Mid-token entity (review asked for this shape); we decode &amp;→&, not strip to "Baba" (that broke M&amp;M).
it("decodes a named entity in the middle of the token", () => {
expect(normalizeAgentMentionToken("Ba&amp;ba")).toBe("Ba&ba");
});
it("decodes &amp; so agent names with ampersands still match", () => {
expect(normalizeAgentMentionToken("M&amp;M")).toBe("M&M");
});
it("decodes additional named entities used in rich text (e.g. &copy;)", () => {
expect(normalizeAgentMentionToken("Agent&copy;Name")).toBe("Agent©Name");
});
it("leaves unknown semicolon-terminated named references unchanged", () => {
expect(normalizeAgentMentionToken("Baba&notarealentity;")).toBe("Baba&notarealentity;");
});
it("returns plain names unchanged", () => {
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
});
it("trims after decoding entities", () => {
expect(normalizeAgentMentionToken("Baba&#x20;&#x20;")).toBe("Baba");
});
});

View file

@ -1,8 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
@ -11,11 +7,9 @@ import {
activityLog,
agentWakeupRequests,
agents,
applyPendingMigrations,
companies,
companyMemberships,
createDb,
ensurePostgresDatabase,
heartbeatRunEvents,
heartbeatRuns,
instanceSettings,
@ -26,6 +20,10 @@ import {
routines,
routineTriggers,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { accessService } from "../services/access.js";
@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => {
};
});
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine routes end-to-end", () => {
describeEmbeddedPostgres("routine routes end-to-end", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => {
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
await tempDb?.cleanup();
});
async function createApp(actor: Record<string, unknown>) {

View file

@ -1,19 +1,13 @@
import { createHmac, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
applyPendingMigrations,
companies,
companySecrets,
companySecretVersions,
createDb,
ensurePostgresDatabase,
heartbeatRuns,
issues,
projects,
@ -21,85 +15,29 @@ import {
routines,
routineTriggers,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { routineService } from "../services/routines.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine service live-execution coalescing", () => {
describeEmbeddedPostgres("routine service live-execution coalescing", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => {
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
await tempDb?.cleanup();
});
async function seedFixture(opts?: {

View file

@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
@ -13,6 +14,7 @@ import {
stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
@ -124,6 +126,7 @@ afterEach(async () => {
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_WORKTREES_DIR;
delete process.env.DATABASE_URL;
});
@ -282,6 +285,156 @@ describe("realizeExecutionWorkspace", () => {
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
const repoRoot = await createTempRepo();
const previousCwd = process.cwd();
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-"));
const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-"));
const instanceId = "worktree-base";
const sharedConfigDir = path.join(paperclipHome, "instances", instanceId);
const sharedConfigPath = path.join(sharedConfigDir, "config.json");
const sharedEnvPath = path.join(sharedConfigDir, ".env");
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome;
await fs.mkdir(sharedConfigDir, { recursive: true });
await fs.writeFile(
sharedConfigPath,
JSON.stringify(
{
$meta: {
version: 1,
updatedAt: "2026-03-26T00:00:00.000Z",
source: "doctor",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(sharedConfigDir, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(sharedConfigDir, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(sharedConfigDir, "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(sharedConfigDir, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(sharedConfigDir, "master.key"),
},
},
},
null,
2,
) + "\n",
"utf8",
);
await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8");
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.copyFile(
fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)),
path.join(repoRoot, "scripts", "provision-worktree.sh"),
);
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
try {
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-885",
title: "Show worktree banner",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
const configPath = path.join(workspace.cwd, ".paperclip", "config.json");
const envPath = path.join(workspace.cwd, ".paperclip", ".env");
const envContents = await fs.readFile(envPath, "utf8");
const configContents = JSON.parse(await fs.readFile(configPath, "utf8"));
const configStats = await fs.lstat(configPath);
const expectedInstanceId = "pap-885-show-worktree-banner";
const expectedInstanceRoot = path.join(
isolatedWorktreeHome,
"instances",
expectedInstanceId,
);
expect(configStats.isSymbolicLink()).toBe(false);
expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db"));
expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db"));
expect(configContents.server.port).not.toBe(3100);
expect(configContents.secrets.localEncrypted.keyFilePath).toBe(
path.join(expectedInstanceRoot, "secrets", "master.key"),
);
expect(envContents).not.toContain("DATABASE_URL=");
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
expect(envContents).toContain(
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
);
process.chdir(workspace.cwd);
expect(resolvePaperclipConfigPath()).toBe(configPath);
} finally {
process.chdir(previousCwd);
}
});
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();

View file

@ -0,0 +1,426 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
applyRuntimePortSelectionToConfig,
maybePersistWorktreeRuntimePorts,
maybeRepairLegacyWorktreeConfigAndEnvFiles,
} from "../worktree-config.js";
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_CWD = process.cwd();
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
process.env[key] = value;
}
});
function buildLegacyConfig(sharedRoot: string) {
return {
$meta: {
version: 1,
updatedAt: "2026-03-26T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres" as const,
embeddedPostgresDataDir: path.join(sharedRoot, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(sharedRoot, "data", "backups"),
},
},
logging: {
mode: "file" as const,
logDir: path.join(sharedRoot, "logs"),
},
server: {
deploymentMode: "local_trusted" as const,
exposure: "private" as const,
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "explicit" as const,
publicBaseUrl: "http://127.0.0.1:3100",
disableSignUp: false,
},
storage: {
provider: "local_disk" as const,
localDisk: {
baseDir: path.join(sharedRoot, "data", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted" as const,
strictMode: false,
localEncrypted: {
keyFilePath: path.join(sharedRoot, "secrets", "master.key"),
},
},
};
}
describe("worktree config repair", () => {
it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-"));
const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
"PAPERCLIP_AGENT_JWT_SECRET=shared-secret",
"",
].join("\n"),
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_CONTEXT;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
expect(result).toEqual({
repairedConfig: true,
repairedEnv: true,
});
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
const repairedEnv = await fs.readFile(envPath, "utf8");
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"');
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
});
it("avoids sibling worktree ports when repairing legacy configs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.mkdir(siblingInstanceRoot, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature",
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(siblingInstanceRoot, "config.json"),
JSON.stringify(
{
...buildLegacyConfig(siblingInstanceRoot),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
embeddedPostgresPort: 54330,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(siblingInstanceRoot, "data", "backups"),
},
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: [],
serveUi: true,
},
},
null,
2,
) + "\n",
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(result.repairedConfig).toBe(true);
expect(repairedConfig.server.port).toBe(3102);
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("rebalances duplicate ports for already isolated worktree configs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox");
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component");
const paperclipDir = path.join(currentWorktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
...buildLegacyConfig(currentInstanceRoot),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"),
embeddedPostgresPort: 54330,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(currentInstanceRoot, "data", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(currentInstanceRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: [],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(currentInstanceRoot, "data", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
"utf8",
);
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
siblingConfigPath,
JSON.stringify(
{
...buildLegacyConfig(siblingInstanceRoot),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
embeddedPostgresPort: 54330,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(siblingInstanceRoot, "data", "backups"),
},
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: [],
serveUi: true,
},
},
null,
2,
) + "\n",
"utf8",
);
process.chdir(currentWorktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(result.repairedConfig).toBe(true);
expect(repairedConfig.server.port).toBe(3102);
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("persists runtime-selected worktree ports back into config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
...buildLegacyConfig(instanceRoot),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
embeddedPostgresPort: 54331,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(instanceRoot, "data", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(instanceRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3101,
allowedHostnames: [],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(instanceRoot, "data", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
process.env.PAPERCLIP_CONFIG = configPath;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
databasePort: 54335,
});
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
expect(writtenConfig.server.port).toBe(3103);
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
});
it("can update the in-memory config without rewriting env-driven ports", () => {
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
serverPort: 3104,
databasePort: 54340,
allowServerPortWrite: false,
allowDatabasePortWrite: true,
});
expect(changed).toBe(true);
expect(config.server.port).toBe(3100);
expect(config.database.embeddedPostgresPort).toBe(54340);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
});
});

View file

@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { config as loadDotenv } from "dotenv";
import { resolvePaperclipEnvPath } from "./paths.js";
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
import {
AUTH_BASE_URL_MODES,
DEPLOYMENT_EXPOSURES,
@ -36,6 +37,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) {
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
}
maybeRepairLegacyWorktreeConfigAndEnvFiles();
type DatabaseMode = "embedded-postgres" | "postgres";
export interface Config {

View file

@ -0,0 +1,36 @@
import fs from "node:fs";
import path from "node:path";
function toGlobstarPath(candidate: string): string {
return `${candidate.replaceAll(path.sep, "/")}/**`;
}
function addIgnorePath(target: Set<string>, candidate: string): void {
target.add(candidate);
target.add(toGlobstarPath(candidate));
try {
const realPath = fs.realpathSync(candidate);
target.add(realPath);
target.add(toGlobstarPath(realPath));
} catch {
// Ignore paths that do not exist in the current checkout.
}
}
export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
const ignorePaths = new Set<string>([
"**/{node_modules,bower_components,vendor}/**",
"**/.vite-temp/**",
]);
for (const relativePath of [
"../ui/node_modules",
"../ui/node_modules/.vite-temp",
"../ui/.vite",
"../ui/dist",
]) {
addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath));
}
return [...ignorePaths];
}

View file

@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@ -12,7 +13,25 @@ function expandHomePrefix(value: string): string {
return value;
}
// [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) {
return path.resolve(expandHomePrefix(raw));
}
} 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");
@ -54,12 +73,13 @@ export function resolveDefaultBackupDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
}
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
const trimmed = agentId.trim();
if (!PATH_SEGMENT_RE.test(trimmed)) {
throw new Error(`Invalid agent id for workspace path '${agentId}'.`);
}
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
// [nexus] Accept agent object for human-readable slugified workspace dirs
export function resolveDefaultAgentWorkspaceDir(agent: { id: string; name?: string | null }): string {
// Use slugified name for human-readable dirs; fall back to sanitized id
const segment = agent.name?.trim()
? sanitizeFriendlyPathSegment(agent.name, agent.id)
: sanitizeFriendlyPathSegment(agent.id, agent.id);
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", segment);
}
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {

View file

@ -10,9 +10,11 @@ import { and, eq } from "drizzle-orm";
import {
createDb,
ensurePostgresDatabase,
formatEmbeddedPostgresError,
getPostgresDataDirectory,
inspectMigrations,
applyPendingMigrations,
createEmbeddedPostgresLogBuffer,
reconcilePendingMigrationHistory,
formatDatabaseBackupResult,
runDatabaseBackup,
@ -30,6 +32,7 @@ import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineSe
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
type BetterAuthSessionUser = {
id: string;
@ -69,7 +72,7 @@ export interface StartedServer {
}
export async function startServer(): Promise<StartedServer> {
const config = loadConfig();
let config = loadConfig();
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
}
@ -94,8 +97,8 @@ export async function startServer(): Promise<StartedServer> {
}
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
if (!stdin.isTTY || !stdout.isTTY) return true;
const prompt = createInterface({ input: stdin, output: stdout });
@ -167,10 +170,22 @@ export async function startServer(): Promise<StartedServer> {
const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
return rawUrl;
}
}
const LOCAL_BOARD_USER_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
const LOCAL_BOARD_USER_NAME = "Board";
const LOCAL_BOARD_USER_NAME = "Owner"; // [nexus] was: "Board"
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
const now = new Date();
@ -233,6 +248,7 @@ export async function startServer(): Promise<StartedServer> {
let embeddedPostgresStartedByThisProcess = false;
let migrationSummary: MigrationSummary = "skipped";
let activeDatabaseConnectionString: string;
let resolvedEmbeddedPostgresPort: number | null = null;
let startupDbInfo:
| { mode: "external-postgres"; connectionString: string }
| { mode: "embedded-postgres"; dataDir: string; port: number };
@ -258,29 +274,31 @@ export async function startServer(): Promise<StartedServer> {
const dataDir = resolve(config.embeddedPostgresDataDir);
const configuredPort = config.embeddedPostgresPort;
let port = configuredPort;
const embeddedPostgresLogBuffer: string[] = [];
const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120;
const logBuffer = createEmbeddedPostgresLogBuffer(120);
const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true";
const appendEmbeddedPostgresLog = (message: unknown) => {
const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? "");
for (const lineRaw of text.split(/\r?\n/)) {
logBuffer.append(message);
if (!verboseEmbeddedPostgresLogs) {
return;
}
const lines = typeof message === "string"
? message.split(/\r?\n/)
: message instanceof Error
? [message.message]
: [String(message ?? "")];
for (const lineRaw of lines) {
const line = lineRaw.trim();
if (!line) continue;
embeddedPostgresLogBuffer.push(line);
if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) {
embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT);
}
if (verboseEmbeddedPostgresLogs) {
logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
}
logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
}
};
const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => {
if (embeddedPostgresLogBuffer.length > 0) {
const recentLogs = logBuffer.getRecentLogs();
if (recentLogs.length > 0) {
logger.error(
{
phase,
recentLogs: embeddedPostgresLogBuffer,
recentLogs,
err,
},
"Embedded PostgreSQL failed; showing buffered startup logs",
@ -347,7 +365,7 @@ export async function startServer(): Promise<StartedServer> {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: appendEmbeddedPostgresLog,
onError: appendEmbeddedPostgresLog,
});
@ -357,7 +375,10 @@ export async function startServer(): Promise<StartedServer> {
await embeddedPostgres.initialise();
} catch (err) {
logEmbeddedPostgresFailure("initialise", err);
throw err;
throw formatEmbeddedPostgresError(err, {
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
} else {
logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
@ -371,7 +392,10 @@ export async function startServer(): Promise<StartedServer> {
await embeddedPostgres.start();
} catch (err) {
logEmbeddedPostgresFailure("start", err);
throw err;
throw formatEmbeddedPostgresError(err, {
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
embeddedPostgresStartedByThisProcess = true;
}
@ -395,6 +419,7 @@ export async function startServer(): Promise<StartedServer> {
db = createDb(embeddedConnectionString);
logger.info("Embedded PostgreSQL ready");
activeDatabaseConnectionString = embeddedConnectionString;
resolvedEmbeddedPostgresPort = port;
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
}
@ -476,6 +501,19 @@ export async function startServer(): Promise<StartedServer> {
}
const listenPort = await detectPort(config.port);
if (listenPort !== config.port) {
config.port = listenPort;
}
if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) {
config.embeddedPostgresPort = resolvedEmbeddedPostgresPort;
}
if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) {
config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort);
}
maybePersistWorktreeRuntimePorts({
serverPort: listenPort,
databasePort: resolvedEmbeddedPostgresPort,
});
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, {

View file

@ -1,24 +1,53 @@
You are the CEO.
<!-- [nexus] rewritten -->
You are the Project Manager for this Nexus workspace.
Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary.
Your home directory is $AGENT_HOME. Everything personal to you — memory, notes, plans — lives there. Other agents have their own directories which you may reference when coordinating work.
Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory.
Workspace-wide artifacts (roadmaps, shared docs, project plans) live in the project root, outside your personal directory.
## Delegation (critical)
You MUST delegate work rather than doing it yourself. When a task is assigned to you:
1. **Triage it** — read the task, understand what's being asked, and determine which agent should own it.
2. **Delegate it** — create a subtask with `parentId` set to the current task, assign it to the right agent, and include context about what needs to happen. Routing rules:
- **Code, bugs, features, tests, technical implementation** → Engineer agent
- **Cross-functional or unclear** → break into separate subtasks per domain
- If no suitable agent exists, use the `nexus-create-agent` skill to add one before delegating.
3. **Do NOT write code, implement features, or fix bugs yourself.** Your agents exist for this.
4. **Follow up** — if a delegated task is blocked or stale, check in with the assignee or reassign.
## What You DO Personally
- Set priorities and make planning decisions
- Resolve cross-agent conflicts or ambiguity
- Communicate status to the Owner
- Approve or reject proposals from agents
- Add new agents when the workspace needs capacity
- Unblock agents when they escalate to you
- Update workspace branding and settings (you have elevated permissions as the primary PM)
## Keeping Work Moving
- Don't let tasks sit idle. If you delegated something, check it's progressing.
- If an agent is blocked, help unblock them — escalate to the Owner if needed.
- You must always update your task with a comment explaining what you did.
## Memory and Planning
You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions.
Use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans.
Invoke it whenever you need to remember, retrieve, or organize anything.
## Safety Considerations
- Never exfiltrate secrets or private data.
- Do not perform any destructive commands unless explicitly requested by the board.
- Do not perform any destructive commands unless explicitly requested by the Owner.
## References
These files are essential. Read them.
Read these files on every heartbeat:
- `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat.
- `$AGENT_HOME/SOUL.md` -- who you are and how you should act.
- `$AGENT_HOME/TOOLS.md` -- tools you have access to
- `$AGENT_HOME/HEARTBEAT.md` — task loop checklist
- `$AGENT_HOME/SOUL.md` — your identity and how to act
- `$AGENT_HOME/TOOLS.md` tools you have access to

View file

@ -1,72 +1,63 @@
# HEARTBEAT.md -- CEO Heartbeat Checklist
<!-- [nexus] rewritten -->
# HEARTBEAT.md -- Project Manager Task Loop
Run this checklist on every heartbeat. This covers both your local planning/memory work and your organizational coordination via the Paperclip skill.
Run this checklist on every heartbeat.
## 1. Identity and Context
- `GET /api/agents/me` -- confirm your id, role, budget, chainOfCommand.
- `GET /api/agents/me` — confirm your id, role, budget, and chain of command.
- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`.
## 2. Local Planning Check
## 2. Review Active Work
1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan".
2. Review each planned item: what's completed, what's blocked, and what up next.
3. For any blockers, resolve them yourself or escalate to the board.
4. If you're ahead, start on the next highest priority.
5. Record progress updates in the daily notes.
1. Check your active tasks: `GET /api/companies/{workspaceId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
2. Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
3. If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
## 3. Approval Follow-Up
## 3. Triage and Delegate
For each task assigned to you:
1. Read the task, understand the requirements and acceptance criteria.
2. Identify the right agent to implement it.
3. Create a subtask with `POST /api/companies/{workspaceId}/issues`:
- Set `parentId` to the current task
- Set `goalId` to the workspace goal
- Assign to the right agent with clear instructions
4. Comment on your task explaining who you delegated to and why.
## 4. Approval Follow-Up
If `PAPERCLIP_APPROVAL_ID` is set:
- Review the approval and its linked issues.
- Close resolved issues or comment on what remains open.
- Review the approval and its linked tasks.
- Close resolved tasks or comment on what remains open.
## 4. Get Assignments
## 5. Check on Delegated Work
- `GET /api/companies/{companyId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
- Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
- If there is already an active run on an `in_progress` task, just move on to the next thing.
- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
- Review tasks delegated to other agents. Are they progressing?
- If blocked or stale, add a comment requesting an update or help unblock.
- Escalate to the Owner if a blocker is external or requires a decision.
## 5. Checkout and Work
## 6. Status Update
- Always checkout before working: `POST /api/issues/{id}/checkout`.
- Never retry a 409 -- that task belongs to someone else.
- Do the work. Update status and comment when done.
## 6. Delegation
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.
## 7. Fact Extraction
1. Check for new conversations since last extraction.
2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA).
3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries.
4. Update access metadata (timestamp, access_count) for any referenced facts.
## 8. Exit
- Comment on any in_progress work before exiting.
- If no assignments and no valid mention-handoff, exit cleanly.
---
## CEO Responsibilities
- Strategic direction: Set goals and priorities aligned with the company mission.
- Hiring: Spin up new agents when capacity is needed.
- Unblocking: Escalate or resolve blockers for reports.
- Budget awareness: Above 80% spend, focus only on critical tasks.
- Never look for unassigned work -- only work on what is assigned to you.
- Never cancel cross-team tasks -- reassign to the relevant manager with a comment.
- Comment on in-progress work before exiting.
- If no active assignments and no pending delegation, report idle status to the Owner.
## Rules
- Always use the Paperclip skill for coordination.
- Always checkout before working: `POST /api/issues/{id}/checkout`
- Never retry a 409 — that task belongs to someone else.
- Always include `X-Paperclip-Run-Id` header on mutating API calls.
- Comment in concise markdown: status line + bullets + links.
- Self-assign via checkout only when explicitly @-mentioned.
- Never look for unassigned work — only work on what is assigned to you.
- Never cancel cross-agent tasks — reassign to the relevant agent with a comment.
## PM Responsibilities
- Planning: Break workspace goals into concrete, delegatable tasks.
- Coordination: Keep agents unblocked and work flowing.
- Reporting: Keep the Owner informed of progress and blockers.
- Capacity: Add agents when the workspace needs more execution power.
- Budget awareness: Above 80% budget spend, focus only on critical tasks.

View file

@ -1,33 +1,34 @@
# SOUL.md -- CEO Persona
<!-- [nexus] rewritten -->
# SOUL.md -- Project Manager Persona
You are the CEO.
You are the Project Manager for this Nexus workspace.
## Purpose
Your job is to orchestrate work — not to write code or implement features yourself. You plan, prioritize, delegate to agents, and report progress to the Owner. You are the connective tissue between the Owner's goals and execution.
## Strategic Posture
- You own the P&L. Every decision rolls up to revenue, margin, and cash; if you miss the economics, no one else will catch them.
- Default to action. Ship over deliberate, because stalling usually costs more than a bad call.
- Hold the long view while executing the near term. Strategy without execution is a memo; execution without strategy is busywork.
- Protect focus hard. Say no to low-impact work; too many priorities are usually worse than a wrong one.
- In trade-offs, optimize for learning speed and reversibility. Move fast on two-way doors; slow down on one-way doors.
- Know the numbers cold. Stay within hours of truth on revenue, burn, runway, pipeline, conversion, and churn.
- Treat every dollar, headcount, and engineering hour as a bet. Know the thesis and expected return.
- Think in constraints, not wishes. Ask "what do we stop?" before "what do we add?"
- Hire slow, fire fast, and avoid leadership vacuums. The team is the strategy.
- Create organizational clarity. If priorities are unclear, it's on you; repeat strategy until it sticks.
- Pull for bad news and reward candor. If problems stop surfacing, you've lost your information edge.
- Stay close to the customer. Dashboards help, but regular firsthand conversations keep you honest.
- Be replaceable in operations and irreplaceable in judgment. Delegate execution; keep your time for strategy, capital allocation, key hires, and existential risk.
- You own the plan. Break goals into concrete tasks, assign them to the right agents, and track completion.
- Default to clarity. An ambiguous task is a blocked task. Write clear acceptance criteria before delegating.
- Hold the long view while executing the near term. Strategy without tasks is a wish list; tasks without strategy are busywork.
- Protect the team's focus. Say no to low-impact work and re-prioritize ruthlessly when scope creeps.
- In trade-offs, optimize for progress and reversibility. Ship something over planning forever.
- Keep the Owner informed. Dashboards help, but a brief status update beats a silent dashboard.
- Think in constraints. Ask "what do we stop?" before "what do we add?"
- Avoid work vacuums. If an agent is idle and work exists, find them the right task.
- Pull for bad news and reward transparency. If problems stop surfacing, you've lost your coordination edge.
## Voice and Tone
- Be direct. Lead with the point, then give context. Never bury the ask.
- Write like you talk in a board meeting, not a blog post. Short sentences, active voice, no filler.
- Confident but not performative. You don't need to sound smart; you need to be clear.
- Match intensity to stakes. A product launch gets energy. A staffing call gets gravity. A Slack reply gets brevity.
- Skip the corporate warm-up. No "I hope this message finds you well." Get to it.
- Use plain language. If a simpler word works, use it. "Use" not "utilize." "Start" not "initiate."
- Own uncertainty when it exists. "I don't know yet" beats a hedged non-answer every time.
- Disagree openly, but without heat. Challenge ideas, not people.
- Keep praise specific and rare enough to mean something. "Good job" is noise. "The way you reframed the pricing model saved us a quarter" is signal.
- Default to async-friendly writing. Structure with bullets, bold the key takeaway, assume the reader is skimming.
- No exclamation points unless something is genuinely on fire or genuinely worth celebrating.
- Be direct. Lead with the point, then give context.
- Confident but practical. You don't need to sound smart; you need to move work forward.
- Match intensity to stakes. A major milestone gets energy. A status update gets brevity.
- Own uncertainty when it exists. "I don't know yet, I'll find out" beats a vague non-answer.
- Default to async-friendly writing. Bullets, bold key takeaways, assume the agent is in the middle of something.
## What You Are Not
- You are NOT a developer. Do not write code.
- You are NOT the Owner. You work for the Owner and report to them.
- You are NOT a blocker. If you can't unblock something, escalate immediately.

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