Compare commits

..

290 Commits

Author SHA1 Message Date
Dotta
4d4d0447cd Merge remote-tracking branch 'public-gh/master' into openclawgateway
* public-gh/master:
  Enhance plugin architecture by introducing agent tool contributions and plugin-to-plugin communication. Update workspace plugin definitions for direct OS access and refine UI extension surfaces. Document new capabilities for plugin settings UI and lifecycle management.
  fix(server): serve cached index.html in SPA catch-all to prevent 500
2026-03-07 18:57:37 -06:00
Dotta
416177ae4c Merge pull request #270 from paperclipai/openclawgateway
Openclaw gateway
2026-03-07 18:57:16 -06:00
Dotta
72cc748aa8 Merge pull request #273 from gsxdsm/feature/plugins
Enhance plugin architecture by introducing agent tool contributions a…
2026-03-07 18:56:00 -06:00
Dotta
9299660388 Merge pull request #269 from mvanhorn/fix/233-spa-fallback-500
fix(server): serve cached index.html in SPA catch-all to prevent 500
2026-03-07 18:54:33 -06:00
Dotta
2cb82f326f Revert lockfile changes from OpenClaw gateway cleanup 2026-03-07 18:54:16 -06:00
gsxdsm
f81d2ebcc4 Enhance plugin architecture by introducing agent tool contributions and plugin-to-plugin communication. Update workspace plugin definitions for direct OS access and refine UI extension surfaces. Document new capabilities for plugin settings UI and lifecycle management. 2026-03-07 16:52:35 -08:00
Dotta
048e2b1bfe Remove legacy OpenClaw adapter and keep gateway-only flow 2026-03-07 18:50:25 -06:00
Dotta
5fae7d4de7 Fix CI typecheck and default OpenClaw sessions to issue scope 2026-03-07 18:33:40 -06:00
Matt Van Horn
0f32fffe79 fix(server): serve cached index.html in SPA catch-all to prevent 500
res.sendFile can emit NotFoundError from the send module in certain
path resolution scenarios, causing 500s on company-scoped SPA routes.
Cache index.html at startup and serve it directly, which is both
more reliable and faster.

Fixes #233

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:20:19 -08:00
Dotta
0233525e99 Add CEO OpenClaw invite endpoint and update onboarding UX 2026-03-07 18:19:06 -06:00
Dotta
2223afa0e9 openclaw gateway: auto-approve first pairing and retry 2026-03-07 17:46:55 -06:00
Dotta
3479ea6e80 openclaw gateway: persist device keys on create/update and clarify pairing flow 2026-03-07 17:34:38 -06:00
Dotta
63a876ca3c Merge pull request #256 from paperclipai/dotta
Dotta
2026-03-07 17:18:46 -06:00
Dotta
df0f101fbd plugin spec draft ideas v0 2026-03-07 17:16:37 -06:00
Dotta
0abb6a1205 openclaw gateway: persist device keys and smoke pairing flow 2026-03-07 17:05:36 -06:00
Dotta
d52f1d4b44 openclaw-gateway: document and surface pairing-mode requirements 2026-03-07 16:32:49 -06:00
Dotta
e27ec5de8c fix(smoke): disambiguate case C ack comment target 2026-03-07 16:16:53 -06:00
Dotta
83488b4ed0 fix(openclaw-gateway): enforce join token validation and add smoke preflight gates 2026-03-07 16:01:19 -06:00
Dotta
271a632f1c fix(openclaw): make invite snippet/onboarding gateway-first 2026-03-07 15:39:12 -06:00
Dotta
9a0e3a8425 Merge pull request #252 from paperclipai/dotta
Dotta updates - sorry it's so large
2026-03-07 15:20:39 -06:00
Dotta
1c1b86f495 fix(issues): guard missing companyId and enrich activity log context
Add 400 response for /issues without companyId, tag issue.updated
activity with source:comment when triggered by a comment, and mark
comment activities with updated:true when field changes accompany them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:19:08 -06:00
Dotta
1420b86aa7 fix(server): attach raw Error to res.err and avoid pino err key collision
Extract attachErrorContext helper to DRY up the error handler, attach the
original Error object to res.err so pino can serialize stack traces, and
rename the log context key from err to errorContext so it doesn't clash
with pino's built-in err serializer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:19:03 -06:00
Dotta
22053d18e4 chore: regenerate pnpm-lock.yaml after merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:18:23 -06:00
Dotta
3b4db7a3bc Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Address PR feedback: keep testEnvironment non-destructive, warn on swallowed errors
  Apply suggestion from @greptile-apps[bot]
  Fix opencode-local adapter: parser, UI, CLI, and environment tests
  Rename Invoke button to Run Heartbeat for clarity
  fixing overhanging recommended text in onboarding
  Add Contributing guide
  feat(pi-local): fix bugs, add RPC mode, improve cost tracking and output handling
  fix(sidebar-badges): include approvals in inbox badge count
  feat: add Pi adapter support to constants and onboarding UI
  Adding support for pi-local
  ci: clarify fail-fast lockfile refresh behavior
  ci: remove unnecessary full-history checkout
  ci: fix pnpm lockfile policy checks
  ci: split workflows and move pnpm lockfile ownership to GitHub Actions
  Add License
  fix: use root option in sendFile to avoid dotfile 500 on SPA refresh

# Conflicts:
#	cli/src/adapters/registry.ts
#	pnpm-lock.yaml
#	server/src/adapters/registry.ts
#	ui/package.json
#	ui/src/adapters/registry.ts
2026-03-07 15:18:02 -06:00
Dotta
db15dfaf5e fix(server): return 404 JSON for unmatched API routes
Add catch-all handler after API router to return a proper 404 JSON
response instead of falling through to the SPA handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:15:51 -06:00
Dotta
1afadd7354 Merge pull request #173 from zvictor/ci
ci: split workflows and move pnpm lockfile ownership to GitHub Actions
2026-03-07 15:15:20 -06:00
Dotta
9ac2e71187 fix(smoke): pin OpenClaw docker harness to stable stock ref 2026-03-07 15:09:52 -06:00
Dotta
3bde21bb06 Merge pull request #240 from aaaaron/fix-opencode-local-adapter-tests
Fix opencode-local adapter tests and behavior
2026-03-07 14:56:31 -06:00
Aaron
672d769c68 Address PR feedback: keep testEnvironment non-destructive, warn on swallowed errors
- Update cwd test to expect an error for missing directories (matches
  createIfMissing: false accepted from review)
- Add warn-level check for non-ProviderModelNotFoundError failures
  during best-effort model discovery when no model is configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:15:10 +00:00
Aaron
46c343f81d Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 14:12:22 -05:00
Aaron
17058dd751 Fix opencode-local adapter: parser, UI, CLI, and environment tests
- Move costUsd to top-level return field in parseOpenCodeJsonl (out of usage)
- Fix session-not-found regex to match "Session not found" pattern
- Use callID for toolUseId in UI stdout parser, add status/metadata header
- Fix CLI formatter: separate tool_call/tool_result lines, split step_finish
- Enable createIfMissing for cwd validation in environment tests
- Add empty OPENAI_API_KEY override detection
- Classify ProviderModelNotFoundError as warning during model discovery
- Make model discovery best-effort when no model is configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:01:04 +00:00
Dotta
dd14643848 ui: unify comment/update issue toasts 2026-03-07 10:32:22 -06:00
Dotta
1dac0ec7cf docs: add manual OpenClaw onboarding checklist 2026-03-07 10:32:22 -06:00
Dotta
7c0a3efea6 feat(ui): add plus button to sidebar AGENTS header
Add a "+" button next to "AGENTS" in the sidebar, matching the existing
pattern used by Projects. Clicking it opens the agent creation choice
modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:19:51 -06:00
Dotta
671a8ae554 Merge pull request #221 from richardanaya/rename-invoke-to-run-heartbeat
Rename Invoke button to Run Heartbeat for UX consistency
2026-03-07 10:17:04 -06:00
Dotta
baa71d6a08 fix(ui): enforce min 2-line height for agent issue titles on dashboard
Short titles now always occupy two lines of vertical space, ensuring
consistent horizontal alignment across agent cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:16:31 -06:00
Richard Anaya
638f2303bb Rename Invoke button to Run Heartbeat for clarity 2026-03-07 08:12:37 -08:00
Dotta
a4d0901e89 Revert "Fix markdown editor escaped list markers"
This reverts commit fbcd80948e.
2026-03-07 10:09:04 -06:00
Dotta
f85f2fbcc2 feat(ui): add copy-as-markdown button to comment headers
Adds a small copy icon to the right of each comment's date that copies
the comment body as raw markdown to the clipboard. Shows a checkmark
briefly after copying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:57:50 -06:00
Dotta
fbcd80948e Fix markdown editor escaped list markers 2026-03-07 09:56:46 -06:00
Dotta
9d6a83dcca docs(openclaw-gateway): record e2e execution findings from stock lane validation
Document observed failures before wake-text fix and successful
stock-clean lane pass after fix. Note instrumentation lane limitations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:52:08 -06:00
Dotta
a251a53571 fix(openclaw): embed explicit heartbeat workflow in wake text
Include the full Paperclip API workflow (checkout, fetch, update) and
endpoint bans in the wake text sent to OpenClaw agents, preventing
them from guessing non-existent endpoints. Applied to both openclaw
and openclaw_gateway adapters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:52:01 -06:00
Dotta
63afce3692 Merge pull request #151 from eltociear/add-license
Add License
2026-03-07 09:50:28 -06:00
Dotta
e07646bade Merge pull request #218 from richardanaya/fix-recommended-pill
fixing overhanging recommended text in onboarding
2026-03-07 09:49:20 -06:00
Richard Anaya
ddb7101fa5 fixing overhanging recommended text in onboarding 2026-03-07 07:47:24 -08:00
Dotta
3f42357e5f Merge pull request #196 from hougangdev/fix/inbox-badge-missing-approvals
fix(sidebar-badges): include approvals in inbox badge count
2026-03-07 09:46:03 -06:00
Dotta
3b08d4d582 Merge pull request #217 from aaaaron/CONTRIBUTING
Add CONTRIBUTING.md guide
2026-03-07 09:39:51 -06:00
Aaron
049f768bc7 Add Contributing guide 2026-03-07 15:38:56 +00:00
Dotta
19c295ec03 Merge pull request #183 from richardanaya/master
Adding support for pi-local
2026-03-07 09:31:35 -06:00
Richard Anaya
a6b5f12daf feat(pi-local): fix bugs, add RPC mode, improve cost tracking and output handling
Major improvements to the Pi local adapter:

Bug Fixes (Greptile-identified):
- Fix string interpolation in models.ts error message (was showing literal ${detail})
- Fix tool matching in parse.ts to use toolCallId instead of toolName for correct
  multi-call handling and result assignment
- Fix dead code in execute.ts by tracking instructionsReadFailed flag

Feature Improvements:
- Switch from print mode (-p) to RPC mode (--mode rpc) to prevent agent from
  exiting prematurely and ensure proper lifecycle completion
- Add stdin command sending via JSON-RPC format for prompt delivery
- Add line buffering in execute.ts to handle partial JSON chunks correctly
- Filter RPC protocol messages (response, extension_ui_request, etc.) from transcript

Cost Tracking:
- Extract cost and usage data from turn_end assistant messages
- Support both Pi format (input/output/cacheRead/cost.total) and generic format
- Add tests for cost extraction and accumulation across multiple turns

All tests pass (12/12), typecheck clean, server builds successfully.
2026-03-07 07:23:44 -08:00
Dotta
4bd6961020 fix(openclaw-gateway): add diagnostics capture and two-lane validation to e2e
Capture run events, logs, issue state, and container logs on failures
or timeouts for debugging. Write compatibility JSON keys for claimed
API key. Add two-lane validation requirement to test plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:22:40 -06:00
Dotta
fd0799fd71 Merge pull request #78 from MumuTW/fix/dashboard-spa-refresh-500
fix: resolve 500 error on SPA route refresh (#48)
2026-03-07 09:05:19 -06:00
Dotta
b91820afd3 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  fix(issues): skip agent wakeup when issue status is backlog
2026-03-07 09:04:39 -06:00
Dotta
0315e4cdc2 docs: add local-cli mode and self-test playbook to paperclip skill
Document the agent local-cli command for manual CLI usage and add a
step-by-step self-test playbook for validating assignment flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:51 -06:00
Dotta
654463c28f feat(cli): add agent local-cli command for skill install and env export
New subcommand to install Paperclip skills for Claude/Codex agents and
print the required PAPERCLIP_* environment variables for local CLI
usage outside heartbeat runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:45 -06:00
Dotta
f1ad727f8e feat(ui): render mermaid diagrams in markdown content
Lazy-load mermaid.js and render fenced mermaid code blocks as inline
SVG diagrams with dark/light mode support. Falls back to showing the
source code on parse errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:39 -06:00
Dotta
10cccc07cd feat: deduplicate project shortnames on create and update
Ensure unique URL-safe shortnames by appending numeric suffixes when
collisions occur. Applied during project creation, update, and company
import flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:34 -06:00
Dotta
a498c268c5 feat: add openclaw_gateway adapter
New adapter type for invoking OpenClaw agents via the gateway protocol.
Registers in server, CLI, and UI adapter registries. Adds onboarding
wizard support with gateway URL field and e2e smoke test script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:29 -06:00
Dotta
fa8499719a ui: remove local toast on issue create 2026-03-07 08:55:19 -06:00
Dotta
1fcc6900ff ui: suppress self-authored issue toasts 2026-03-07 08:42:58 -06:00
Dotta
45708a06f1 ui: avoid duplicate and self comment toasts 2026-03-07 08:31:59 -06:00
Dotta
792397c2a9 feat(ui): add agent creation choice modal and full-page config
Replace the direct agent config dialog with a choice modal offering two
paths: "Ask the CEO to create a new agent" (opens pre-filled issue) or
"I want advanced configuration myself" (navigates to /agents/new).

- Extend NewIssueDefaults with title/description for pre-fill support
- Add /agents/new route with full-page agent configuration form
- NewAgentDialog now shows CEO recommendation modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:26:49 -06:00
hougangdev
36e4e67025 fix(sidebar-badges): include approvals in inbox badge count
When a company has "require board approval for new agents" enabled,
hiring an agent creates a pending approval that requires the user
(as a board member) to approve before the agent can start working.
However, the sidebar inbox badge did not include pending approvals
in its count, so there was no visual indicator that action was needed.
Users had no way of knowing an approval was waiting unless they
happened to open the Inbox page manually.

The root cause: the sidebar-badges service correctly included
approvals in the inbox total, but the route handler overwrites
badges.inbox to add alertsCount and staleIssueCount — and in
doing so dropped badges.approvals from the sum.

Add badges.approvals to the inbox count recalculation so that
pending and revision-requested approvals surface in the sidebar
notification badge alongside failed runs, alerts, stale work,
and join requests.

Affected files:
- server/src/routes/sidebar-badges.ts
2026-03-07 17:38:07 +08:00
Richard Anaya
6077ae6064 feat: add Pi adapter support to constants and onboarding UI 2026-03-06 18:47:44 -08:00
Richard Anaya
eb7f690ceb Adding support for pi-local 2026-03-06 18:29:38 -08:00
zvictor
ef0e08b8ed ci: clarify fail-fast lockfile refresh behavior 2026-03-06 21:49:13 -03:00
zvictor
3bcdf3e3ad ci: remove unnecessary full-history checkout 2026-03-06 21:40:05 -03:00
zvictor
fccec94805 ci: fix pnpm lockfile policy checks 2026-03-06 21:29:16 -03:00
zvictor
bee9fdd207 ci: split workflows and move pnpm lockfile ownership to GitHub Actions 2026-03-06 21:21:28 -03:00
Dotta
0ae5d81deb fix(ui): show agent issue title as two lines on dashboard
Change the issue title in agent run cards from single-line truncate
to line-clamp-2 so titles always occupy two lines for consistent card height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:09:21 -06:00
Dotta
ffc59f5b08 Merge pull request #159 from Logesh-waran2003/logesh/fix-backlog-issue-wake
fix(issues): skip agent wakeup when issue status is backlog
2026-03-06 16:57:17 -06:00
Dotta
f5f8c4a883 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  skip agent self-wake on comment and closed-issue wake without reopen
2026-03-06 16:52:11 -06:00
Dotta
e693e3d466 feat(release): auto-create GitHub Release on publish
Add create_github_release helper to release script that creates a
GitHub Release via gh CLI after tagging, using release notes from
releases/vX.Y.Z.md if available or auto-generated notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:50:25 -06:00
Dotta
e4928f3a10 feat(openclaw): support x-openclaw-token header alongside legacy x-openclaw-auth
Accept x-openclaw-token as the preferred auth header for OpenClaw
invite/join flows, falling back to x-openclaw-auth for backwards
compatibility. Update diagnostics messages accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:50:20 -06:00
Dotta
514dc43923 feat(openclaw): support /hooks/agent endpoint and multi-endpoint detection
Add OpenClawEndpointKind type to distinguish between /hooks/wake,
/hooks/agent, open_responses, and generic endpoints. Build appropriate
payloads per endpoint kind with optional sessionKey inclusion.
Refactor webhook execution to use endpoint-aware payload construction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:50:15 -06:00
Dotta
b539462319 Revert "openclaw: force webhook transport to use hooks/wake"
This reverts commit aa7e069044.
2026-03-06 16:18:26 -06:00
Dotta
aa7e069044 openclaw: force webhook transport to use hooks/wake 2026-03-06 16:11:11 -06:00
Dotta
3b0ff94e3f Merge pull request #154 from cschneid/fix/no-self-wake-on-comment
skip agent self-wake on comment and closed-issue wake without reopen
2026-03-06 16:05:28 -06:00
Dotta
5ab1c18530 fix openclaw webhook payload for /v1/responses 2026-03-06 15:50:08 -06:00
Dotta
36013c35d9 dev: make pnpm dev watch workspace package changes 2026-03-06 15:48:35 -06:00
Dotta
b155415d7d Reintroduce OpenClaw webhook transport alongside SSE 2026-03-06 15:15:24 -06:00
Logesh
d7f68ec1c9 fix(issues): skip agent wakeup when issue status is backlog
Agents were being woken immediately when an issue was created or
reassigned with status "backlog", defeating the purpose of backlog
as "not ready to work on yet" (issue #96).

Added `&& issue.status !== "backlog"` guard to both the CREATE and
PATCH wakeup paths, mirroring the existing done/cancelled suppression
pattern already present in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 02:40:53 +05:30
Dotta
af09510f6a fix openclaw openresponses terminal event detection 2026-03-06 14:56:40 -06:00
Dotta
a2bdfb0dd3 stream live run detail output via websocket 2026-03-06 14:39:49 -06:00
Dotta
67247b5d6a feat(ui): hide sensitive OpenClaw fields behind password toggle with eye icon
Webhook auth header and gateway auth token fields now render as password
inputs by default, with an eye/eye-off toggle icon on the left to reveal
the value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:36:02 -06:00
Dotta
5f2dfcb94e fix(ui): prevent long issue titles from overflowing timestamp in inbox
Add min-w-0 to the flex container so truncate works correctly on titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:30:02 -06:00
Dotta
67491483b7 feat(ui): show toast notification for new join requests
When a join request arrives via WebSocket live event, display a toast
prompting the user to review it in the inbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:28:25 -06:00
Dotta
54a4f784a4 fix(ui): clickable unread dot in inbox with fade-out, remove empty circle for read issues
In the My Recent Issues section, the blue unread dot is now a button that
marks the issue as read on click with a smooth opacity fade-out. Already-read
issues show empty space instead of a hollow circle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:27:12 -06:00
Dotta
5aecb148a2 Clarify missing companyId error for malformed issues path 2026-03-06 14:25:34 -06:00
Dotta
f49a003bd9 fix: improve invite onboarding text and callback reachability prompt
Add skill URL note to onboarding text document and strengthen callback
reachability test language in company settings invite snippet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:16:39 -06:00
Dotta
feb384acca feat(ui): coalesce streaming deltas and deduplicate live feed items
Merge consecutive assistant/thinking deltas into a single feed entry
instead of creating one per chunk. Add dedupeKey to FeedItem, increase
streaming text cap to 4000 chars, and bump seen-keys limit to 6000.
Applied consistently to both ActiveAgentsPanel and LiveRunWidget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:16:34 -06:00
Dotta
c9718dc27a Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  fix(auth): apply effective trusted origins and honor allowed hostnames in public mode
  fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode
  fix: parseBooleanFromEnv silently treats common truthy values as false
  set `PAPERCLIP_PUBLIC_URL` in compose files
  centralize URLs into single canonical URL var
2026-03-06 14:15:55 -06:00
Dotta
0b42045053 Auto-deduplicate agent shortname on join request approval
When approving an agent join request with a shortname already in use,
append a numeric suffix (e.g. "openclaw-2") instead of returning an error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:54:58 -06:00
Dotta
d8f7c6bf81 Brighten destructive/error red in dark mode for readability
The dark-mode --destructive color was too dim (lightness 0.396) to read
against dark backgrounds. Bumped to 0.637 lightness with higher chroma
so error text is clearly visible. Set --destructive-foreground to white
for contrast on destructive button backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:53:08 -06:00
Dotta
c8bd578415 fix run detail live log polling when log ref is delayed 2026-03-06 13:24:20 -06:00
Chris Schneider
5dfd9a2429 skip agent self-wake on comment and closed-issue wake without reopen 2026-03-06 19:21:08 +00:00
Dotta
0324259da3 Handle single-key wrapped OpenClaw auth headers 2026-03-06 12:49:41 -06:00
Dotta
7af9aa61fa Merge pull request #99 from zvictor/canonical-url
feat: Canonical Public URL for Authenticated Deployments (`PAPERCLIP_PUBLIC_URL`)
2026-03-06 12:41:58 -06:00
zvictor
55bb3012ea fix(auth): apply effective trusted origins and honor allowed hostnames in public mode 2026-03-06 15:39:36 -03:00
Victor Duarte
ca919d73f9 Merge branch 'master' into canonical-url 2026-03-06 19:32:29 +01:00
Dotta
70051735f6 Fix OpenClaw invite header normalization compatibility 2026-03-06 12:31:58 -06:00
Dotta
2ad616780f Include join requests in inbox badge and auto-refresh via push
The sidebar badge count was missing join requests from its inbox total,
and the live updates provider had no handler for join_request entity
type, so new join requests wouldn't appear until manual page refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:13:25 -06:00
Dotta
fa43e5b0dd Accept OpenClaw auth headers in more payload formats 2026-03-06 12:06:08 -06:00
Dotta
1d42b6e726 ci: add github actions verification workflow 2026-03-06 12:01:25 -06:00
Dotta
a3493dbb74 Allow OpenClaw invite reaccept to refresh join defaults 2026-03-06 11:59:13 -06:00
Ikko Ashimine
59a07324ec Add License 2026-03-07 02:57:28 +09:00
Dotta
4d8663ebc8 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Fix review feedback: duplicate wizard entry, command resolution, @types/node
  Fix server: remove DEFAULT_OPENCODE_LOCAL_MODEL from agents route
  Fix TS errors: remove DEFAULT_OPENCODE_LOCAL_MODEL references
  Regenerate pnpm-lock.yaml after PR #62 merge
  fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode
  fix: parseBooleanFromEnv silently treats common truthy values as false
  `onboard` now derives defaults from env vars before writing config
  Use precomputed runtime env in OpenCode execute
  Fix remaining OpenCode review comments
  Address PR feedback for OpenCode integration
  Add OpenCode provider integration and strict model selection
2026-03-06 11:56:42 -06:00
Dotta
2e7bf85e7a Fix 500 error logs still showing generic pino-http message
The previous fix (8151331) set res.err but pino-http wasn't picking it
up (likely Express 5 response object behavior). Switch to a custom
__errorContext property on the response that customErrorMessage and
customProps read directly, bypassing pino-http's unreliable res.err
check. Remove duplicate manual logger.error calls from the error
handler since pino-http now gets the full context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:41:06 -06:00
Dotta
35e4897256 Merge pull request #141 from aaaaron/integrate-opencode-pr62
Integrate opencode pr62 & pr104
2026-03-06 11:32:03 -06:00
Dotta
68ee3f8ea0 Merge pull request #91 from zvictor/onboard-cherry
feat: Make `paperclipai onboard` Environment-Aware
2026-03-06 11:24:58 -06:00
Dotta
cf1ccd1e14 Assign invite-joined agents to company CEO 2026-03-06 11:22:24 -06:00
Dotta
f56901b473 Clarify OpenClaw claimed API key handling 2026-03-06 11:21:55 -06:00
Dotta
cec372f9bb Fix phantom inbox badge count when failed runs exist
The server-side badge counted agent error alerts independently of failed
runs, but the UI suppresses agent error alerts when individual failed run
cards are already shown. This mismatch caused the badge to show e.g. 2
while only 1 item was visible. Align server logic with the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:01:29 -06:00
Dotta
8355dd7905 Strengthen OpenClaw onboarding auth-token requirements 2026-03-06 11:00:44 -06:00
Dotta
8151331375 Enrich 500 error logs with request context and actual error messages
- pino-http customErrorMessage now includes the real error message
- customProps includes reqBody, reqParams, reqQuery, and routePath for 4xx/5xx
- Error handler logs full request context (body, params, query) for both
  HttpError 500s and unhandled errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:58:22 -06:00
Aaron
b06e41bed2 Fix review feedback: duplicate wizard entry, command resolution, @types/node
- Remove duplicate opencode_local adapter entry in OnboardingWizard
  (old Code-icon version), keeping only the OpenCodeLogoIcon entry
- Extract resolveOpenCodeCommand() helper to deduplicate the
  PAPERCLIP_OPENCODE_COMMAND env-var fallback logic in models.ts
- Bump @types/node from ^22.12.0 to ^24.6.0 to match the monorepo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:53:50 +00:00
Dotta
1179d7e75a Log redacted OpenClaw outbound payload details 2026-03-06 10:38:16 -06:00
Dotta
2ec2dcf9c6 Fix OpenClaw auth propagation and debug visibility 2026-03-06 10:14:57 -06:00
Dotta
cbce8bfbc3 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  fix(ui): wrap failed run card actions on mobile
  feat(codex): add gpt-5.4 to codex_local model list
  persist paperclip data in a named volume
  add support to `cursor` and `opencode` in containerized instances
  force `@types/node@24` in the server
  Add artifact-check to fail fast on broken builds
  remove an insecure default auth secret
  expose `PAPERCLIP_ALLOWED_HOSTNAMES` in compose files
  wait for a health db
  update lock file
  update typing to node v24 from v20
  add missing `openclaw` adapter from deps stage
  fix incorrect pkg scope
  update docker base image
  move docker into `authenticated` deployment mode
2026-03-06 10:14:11 -06:00
Dotta
0f895a8cf9 Enforce 10-minute TTL for generated company invites 2026-03-06 10:10:23 -06:00
Dotta
c3ac209e5f Auto-copy agent snippet to clipboard on generation
When "Generate agent snippet" is clicked, the snippet is now
automatically copied to the clipboard and the "Copied" delight
animation is shown, matching the existing manual copy behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:00:34 -06:00
Dotta
192d76678e Prevent duplicate agent shortnames per company 2026-03-06 09:54:27 -06:00
Aaron
7bcf994064 Fix server: remove DEFAULT_OPENCODE_LOCAL_MODEL from agents route
Same issue as the UI fix — merge conflict resolution kept HEAD's
reference to the removed constant. OpenCode uses strict model
selection with no default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:48:31 +00:00
Dotta
e670324334 Adjust recent issue sorting to ignore self-comment bumps 2026-03-06 09:46:08 -06:00
Dotta
c23ddbad3f Refine inbox ordering and alert-focused badges 2026-03-06 09:42:47 -06:00
Dotta
e6339e911d Fix OpenClaw invite accept config mapping and logging 2026-03-06 09:36:20 -06:00
Dotta
c0c64fe682 Refine touched issue unread indicators in inbox 2026-03-06 09:35:36 -06:00
Aaron
ae60879507 Fix TS errors: remove DEFAULT_OPENCODE_LOCAL_MODEL references
PR #62 uses strict model selection with no default — the merge
conflict resolution incorrectly kept HEAD's references to this
removed constant. Also remove dead opencode_local branch in
OnboardingWizard (already handled by prior condition).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:30:13 +00:00
Aaron
de60519ef6 Regenerate pnpm-lock.yaml after PR #62 merge
The lockfile was out of sync with the merged package.json files
(adapter-utils @types/node bumped to ^24.6.0 by PR #62).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:28:32 +00:00
Aaron
44a00596a4 Merge PR #62: Full OpenCode adapter integration
Merges paperclipai/paperclip#62 onto latest master (494448d).
Adds complete OpenCode provider with strict model selection,
dynamic model discovery, CLI/server/UI adapter registration.

Resolved conflicts with master's cursor adapter additions,
node v24 typing, and containerized opencode support (201d91b).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:23:55 +00:00
Dotta
a57732f7dd fix(ui): switch service worker to network-first to prevent stale content
The cache-first strategy for static assets was causing pages to serve
stale content on refresh. Switched to network-first for all requests
with cache as offline-only fallback. Bumped cache version to clear
existing caches on activate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:08:39 -06:00
Dotta
63c0e22a2a Handle unresolved agent shortnames without UUID errors 2026-03-06 09:06:55 -06:00
Dotta
2405851436 Normalize derived issue timestamps to avoid 500s 2026-03-06 09:03:27 -06:00
Dotta
d9d2ad209d prompt 2026-03-06 08:42:28 -06:00
zvictor
e1d4e37776 fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode 2026-03-06 11:41:11 -03:00
zvictor
08ac2bc9a7 fix: parseBooleanFromEnv silently treats common truthy values as false 2026-03-06 11:41:11 -03:00
Dotta
b213eb695b Add OpenClaw Paperclip API URL override for onboarding 2026-03-06 08:39:29 -06:00
Dotta
494448dcf7 Merge pull request #118 from MumuTW/fix/inbox-mobile-overflow-117
fix: wrap failed run card controls on mobile inbox
2026-03-06 08:37:42 -06:00
Dotta
854e818b74 Improve OpenClaw delta parsing and live stream coalescing 2026-03-06 08:34:51 -06:00
Dotta
38d3d5fa59 Persist issue read state and clear unread on open 2026-03-06 08:34:19 -06:00
Dotta
86bd26ee8a fix(ui): stabilize issue search URL sync and debounce query execution 2026-03-06 08:32:16 -06:00
zvictor
9cacf4a981 fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode 2026-03-06 11:29:28 -03:00
zvictor
9184cf92dd fix: parseBooleanFromEnv silently treats common truthy values as false 2026-03-06 11:28:31 -03:00
Dotta
38b9a55eab Add touched/unread inbox issue semantics 2026-03-06 08:21:03 -06:00
Dotta
3369a9e685 feat(openclaw): add adapter hire-approved hooks 2026-03-06 08:17:42 -06:00
Dotta
553c939f1f Merge pull request #67 from zvictor/master
epic: Fix and improve Docker deployments
2026-03-06 08:14:04 -06:00
Dotta
67bc601258 fix(ui): use history.replaceState for search URL sync to prevent page re-renders
setSearchParams from React Router triggers router state updates that cause
the Issues component tree to re-render on each debounced URL update. Switch
to window.history.replaceState which updates the URL silently without
triggering React Router re-renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:06:57 -06:00
Dotta
9d570b3ed7 fix(ui): enable scroll wheel in selectors inside dialogs
Radix Dialog wraps content in react-remove-scroll, which blocks wheel
events on portaled Popover content (rendered outside the Dialog DOM
tree). Add a disablePortal option to PopoverContent and use it for all
InlineEntitySelector instances inside NewIssueDialog so the Popover
stays in the Dialog's DOM tree and scrolling works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:04:35 -06:00
Dotta
d4eb502389 feat(ui): unify comment assignee selector with icons and fix click flash
Add renderTriggerValue/renderOption to the comment thread's assignee
selector so it shows agent icons, matching the new issue dialog. Fix
the InlineEntitySelector flash on click by only auto-opening on
keyboard focus (not pointer-triggered focus).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:58:30 -06:00
Dotta
50276ed981 Fix OpenClaw wake env injection for OpenResponses input 2026-03-06 07:48:38 -06:00
Dotta
2d21045424 feat(ui): sync issues search with URL query parameter
Debounces search input (300ms) and syncs it to a ?q= URL parameter so
searches persist across navigation and can be shared via URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:46:44 -06:00
Dotta
eb607f7df8 feat(ui): auto-hide sidebar scrollbar when not hovering
Replace scrollbar-none with a scrollbar-auto-hide utility that keeps the
scrollbar thumb transparent by default and reveals it on container hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:39:35 -06:00
Dotta
eb033a221f feat(ui): add dismiss buttons to inbox errors and failures
Failed runs, alerts, and stale work items can now be dismissed via an
X button that appears on hover. Dismissed items are stored in
localStorage and filtered from the inbox view and item count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:27:35 -06:00
Victor Duarte
5f6e68e7aa Merge branch 'paperclipai:master' into master 2026-03-06 12:10:01 +01:00
MumuTW
88682632f9 fix(ui): wrap failed run card actions on mobile 2026-03-06 07:47:11 +00:00
MumuTW
264d40e6ca fix: use root option in sendFile to avoid dotfile 500 on SPA refresh
When served via npx, the absolute path to index.html traverses .npm,
triggering Express 5 / send's dotfile guard. Using the root option
makes send only check the relative filename for dotfiles.

Fixes #48
2026-03-06 07:15:15 +00:00
Dotta
de7d6294ea feat(ui): add scroll-to-bottom button on issues page
Shows a floating arrow button in the bottom-right corner when the user
is more than 300px from the bottom of the issues list. Hides
automatically when near the bottom. Smooth-scrolls on click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:42:35 -06:00
Dotta
f41373dc46 feat(ui): make project status clickable in properties pane
Add a ProjectStatusPicker component that renders the status badge as a
clickable button opening a popover with all project statuses. Falls back
to the read-only StatusBadge when no onUpdate handler is provided.
Works in both desktop side panel and mobile sheet layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:00:23 -06:00
Dotta
1bbb98aaa9 fix(ui): add mobile properties toggle on project detail page
On mobile, the sidebar panel toggle was hidden (md:flex only). Add a
mobile-visible SlidersHorizontal button that opens a bottom Sheet drawer
with ProjectProperties, matching the existing pattern from IssueDetail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:57:48 -06:00
Dotta
cecb94213d smoke: default openclaw docker ui state to temp folder 2026-03-05 18:53:07 -06:00
Dotta
0cdc9547d9 fix(ui): close sidebar on mobile when command palette opens
When searching via the command palette on mobile, the left sidebar
now automatically closes so search results are visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:23:36 -06:00
Dotta
4c1504872f Merge pull request #110 from artokun/feat/codex-add-gpt-5.4-model
feat(codex): add gpt-5.4 to model list
2026-03-05 18:22:42 -06:00
Arthur R Longbottom
7086ad00ae feat(codex): add gpt-5.4 to codex_local model list
Add the newly released gpt-5.4 model to the codex_local adapter's
available models list.

にゃ~ 🐱
2026-03-05 16:13:29 -08:00
Dotta
222e0624a8 smoke: fully reset openclaw docker ui state by default 2026-03-05 17:38:06 -06:00
Dotta
81bc8c7313 Improve OpenClaw SSE transcript parsing and stream readability 2026-03-05 17:26:00 -06:00
Dotta
5134cac993 fix(ui): fix mobile popover issues in InlineEntitySelector
Force popover to always open downward (side="bottom") to prevent it from
flipping upward and going off-screen on mobile. Skip auto-focusing the
search input on touch devices so the virtual keyboard doesn't open and
reshape the viewport. Add touch-manipulation on option buttons to remove
tap delays and improve scroll gesture recognition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:23:01 -06:00
Dotta
e401979851 fix(ui): vertically center close button in command palette on mobile
The X close button in the command palette dialog used the generic dialog
positioning (absolute top-4 right-4), which was visually offset from the
search input on mobile. Replace with a custom close button that matches
the input wrapper height (h-12) and uses flex centering for proper
vertical alignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:21:45 -06:00
Dotta
4569d57f5b fix(ui): improve mobile layout for assignee/project selectors in new issue dialog
Allow the "For [Assignee] in [Project]" row to wrap on mobile instead of
requiring horizontal scroll that pushes selectors off-screen. On desktop
(sm+), the row stays inline with min-w-max. Added overscroll-x-contain
for better touch scroll containment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:18:15 -06:00
Dotta
eff0c506fa fix(ui): hide assignee name in search results on mobile
On small screens, the assignee Identity badge takes up valuable space
in the command palette search results, truncating issue titles. Hide
it below the `sm` breakpoint so more of the title is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:18:01 -06:00
Dotta
c486bad2dd fix(ui): restore mobile touch scroll in popover dropdowns inside dialogs
Radix Dialog's modal DismissableLayer calls preventDefault() on pointerdown
events originating outside the Dialog DOM tree. Popover portals render at the
body level (outside the Dialog), so touch events on popover content were
treated as 'outside' — killing scroll gesture recognition on mobile.

Fix: add onPointerDownOutside to NewIssueDialog's DialogContent that detects
events from Radix popper wrappers and calls event.preventDefault() on the
Radix event (not the native event), which skips the Dialog's native
preventDefault and restores touch scrolling.

Also cleans up previous CSS-only workarounds (-webkit-overflow-scrolling,
touch-pan-y on individual buttons) that couldn't override JS preventDefault.
2026-03-05 17:04:25 -06:00
Dotta
0e387426fa feat(ui): add PWA configuration with service worker and enhanced manifest
Adds service worker with network-first navigation (SPA fallback) and
cache-first static assets. Enhances web manifest with start_url, scope,
id, description, orientation, and maskable icon entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:01:07 -06:00
Dotta
6ee4315eef feat(ui): add gateway config guidance to agent invite snippet
Add OpenResponses gateway enablement instructions to the end of the
agent snippet in CompanySettings. Refactor buildAgentSnippet to use
a template literal for easier future editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:13:43 -06:00
Dotta
7c07b16f80 feat(release): add --canary and --promote flags to release.sh
Support two-phase canary release flow:
- --canary: publishes packages under @canary npm tag, skips git commit/tag
- --promote <version>: moves canary packages to @latest, then commits and tags

Both flags work with --dry-run. Existing behavior unchanged when neither flag is passed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:03:49 -06:00
Dotta
77500b50d9 docs(skills): add canary release flow to release coordination skill
New Steps 3-5 in the release skill:
- Step 3: Publish canary to npm with --tag canary (latest untouched)
- Step 4: Smoke test canary in Docker (uses existing smoke infrastructure)
- Step 5: Promote canary to latest after verification

Also updates idempotency table for new intermediate states (canary
published but not promoted) and adds release flow summary.

Script changes still needed: --canary flag for release.sh, --promote
command for dist-tag promotion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:57:23 -06:00
Dotta
0cc75c6e10 Cut over OpenClaw adapter to strict SSE streaming 2026-03-05 15:54:55 -06:00
zvictor
82d97418b2 set PAPERCLIP_PUBLIC_URL in compose files 2026-03-05 18:48:41 -03:00
Dotta
35a7acc058 fix(ui): add properties panel toggle to project detail page
When the properties pane was closed on a project page, there was no way
to reopen it. Add the same SlidersHorizontal toggle button used on issue
detail pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:25:19 -06:00
Dotta
bd32c871b7 fix(run-log-store): close fd leak in log append that caused spawn EBADF
The append method created a new WriteStream for every log chunk and resolved
the promise on the end callback (data flushed) rather than the close event
(fd released). Over many agent runs the leaked fds corrupted the fd table,
causing child_process.spawn to fail with EBADF.

Replace with fs.appendFile which properly opens, writes, and closes the fd
before resolving.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:23:25 -06:00
Dotta
8e63dd44b6 openclaw: accept webhook json ack in sse mode 2026-03-05 15:16:26 -06:00
zvictor
4eedf15870 persist paperclip data in a named volume 2026-03-05 18:11:50 -03:00
Dotta
a0e6ad0b7d openclaw: preserve run metadata in wake compatibility payload 2026-03-05 15:06:23 -06:00
Dotta
4b90784183 fix(openclaw): fallback to wake compatibility for /hooks/wake in sse mode 2026-03-05 14:56:49 -06:00
zvictor
ab6ec999c5 centralize URLs into single canonical URL var 2026-03-05 17:55:34 -03:00
Dotta
babea25649 feat(openclaw): add SSE-first transport and session routing 2026-03-05 14:28:59 -06:00
Dotta
e9ffde610b docs: add tailscale private access guide 2026-03-05 14:21:47 -06:00
Dotta
a05aa99c7e fix(openclaw): support /hooks/wake compatibility payload 2026-03-05 13:43:37 -06:00
Dotta
690149d555 Fix 500 error logging to show actual error instead of generic message
pino-http checks res.err before falling back to its generic
"failed with status code 500" error. Set res.err to the real error
in the error handler so logs include the actual message and stack trace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:28:26 -06:00
zvictor
ffd1631b14 onboard now derives defaults from env vars before writing config 2026-03-05 16:17:26 -03:00
Dotta
185317c153 Simplify settings invite UI to snippet-only flow 2026-03-05 13:12:07 -06:00
Dotta
988f1244e5 Add invite callback-resolution test endpoint and snippet guidance 2026-03-05 13:05:04 -06:00
Dotta
38b855e495 Polish invite links and agent snippet UX 2026-03-05 12:52:39 -06:00
Dotta
0ed0c0abdb Add company settings invite fallback snippet 2026-03-05 12:37:56 -06:00
Dotta
7a2ecff4f0 Add invite onboarding network host suggestions 2026-03-05 12:28:27 -06:00
Dotta
bee24e880f Add Paperclip host networking guidance to OpenClaw smoke script 2026-03-05 12:22:14 -06:00
Dotta
7ab5b8a0c2 Add blue dot indicator to browser tab when issue has running runs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:20:43 -06:00
Dotta
089a2d08bf Add agent invite message flow and txt onboarding link UX 2026-03-05 12:10:01 -06:00
Dotta
d8fb93edcf Auto-copy invite link on creation and replace Copy button with inline icon
- Invite link is now automatically copied to clipboard when created
- "Copied" badge appears next to the share link header for 2s
- Standalone "Copy link" button replaced with a small copy icon next to the link
- Icon toggles to a green checkmark while "copied" feedback is shown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:00:38 -06:00
zvictor
201d91b4f5 add support to cursor and opencode in containerized instances 2026-03-05 14:53:42 -03:00
Dotta
9da1803f29 docs: add user-facing changelog for v0.2.7
Generated via release-changelog skill. Covers onboarding
resilience improvements, Docker flow fixes, markdown rendering
fix, and embedded postgres dependency fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:38 -06:00
Dotta
1b98c2b279 Isolate OpenClaw smoke state to avoid stale auth drift 2026-03-05 11:42:50 -06:00
Dotta
a85511dad2 Add idempotency guards to release skills
- release-changelog: Step 0 checks for existing changelog file before
  generating. Asks reviewer to keep/regenerate/update. Never overwrites
  silently. Clarifies this skill never triggers version bumps.
- release: Step 0 idempotency table covering all steps. Tag check before
  npm publish prevents double-release. Task search before creation prevents
  duplicate follow-up tasks. Supports iterating on changelogs pre-publish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:38:09 -06:00
zvictor
f75a4d9589 force @types/node@24 in the server 2026-03-05 14:37:48 -03:00
zvictor
0d36cf00f8 Add artifact-check to fail fast on broken builds 2026-03-05 14:36:00 -03:00
zvictor
4b8e880a96 remove an insecure default auth secret 2026-03-05 14:36:00 -03:00
zvictor
1e5e09f0fa expose PAPERCLIP_ALLOWED_HOSTNAMES in compose files 2026-03-05 14:36:00 -03:00
zvictor
57db28e9e6 wait for a health db 2026-03-05 14:36:00 -03:00
zvictor
c610951a71 update lock file 2026-03-05 14:36:00 -03:00
zvictor
e5049a448e update typing to node v24 from v20 2026-03-05 14:36:00 -03:00
zvictor
1f261d90f3 add missing openclaw adapter from deps stage 2026-03-05 14:36:00 -03:00
zvictor
d2dd8d0cc5 fix incorrect pkg scope 2026-03-05 14:36:00 -03:00
Victor Duarte
e08362b667 update docker base image 2026-03-05 14:36:00 -03:00
Victor Duarte
2c809d55c0 move docker into authenticated deployment mode 2026-03-05 14:36:00 -03:00
Dotta
529d53acc0 Pin OpenClaw Docker UI smoke defaults to OpenAI models 2026-03-05 11:29:29 -06:00
Dotta
fd73d6fcab docs(skills): add release coordination workflow 2026-03-05 11:23:58 -06:00
Dotta
cdf63d0024 Add release-changelog skill and releases/ directory
Introduces the release-changelog skill (skills/release-changelog/SKILL.md)
that teaches agents to generate user-facing changelogs from git history,
changesets, and merged PRs. Includes breaking change detection, categorization
heuristics, and structured markdown output template.

Also creates the releases/ directory convention for storing versioned
release notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:21:44 -06:00
Dotta
09a8ecbded Sort assignee picker: recent selections first, then alphabetical
Tracks most recently selected assignees in localStorage and sorts both
the issue properties and new issue dialog assignee lists accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:19:56 -06:00
Dotta
6f98c5f25c Clarify zero-flag OpenClaw Docker UI smoke defaults 2026-03-05 11:14:30 -06:00
Dotta
70e41150c5 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  fix: skip pending_approval agents in heartbeat tickTimers
2026-03-05 11:08:11 -06:00
Dotta
bc765b0867 Merge pull request #72 from STRML/fix/heartbeat-pending-approval
fix: skip pending_approval agents in heartbeat tickTimers
2026-03-05 11:06:35 -06:00
Dotta
9dbd72cffd Improve OpenClaw Docker UI smoke pairing ergonomics 2026-03-05 11:04:14 -06:00
Dotta
084c0a19a2 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  fix(ui): render sub-goals in goal detail tree
  fix: exclude terminated agents from list and org chart endpoints
2026-03-05 11:03:55 -06:00
Dotta
85f95c4542 Add permalink anchors to comments and GET comment-by-ID API
- Comment dates are now clickable anchor links (#comment-{id})
- Pages scroll to and highlight the target comment when URL has a hash
- Added GET /api/issues/:id/comments/:commentId endpoint
- Updated skill docs with new endpoint and comment URL format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:02:22 -06:00
Dotta
732ae4e46c feat(cursor): compact shell tool calls and format results in run log
Show only the command for shellToolCall/shell inputs instead of the
full payload. Format shell results with exit code + truncated
stdout/stderr sections for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:59:49 -06:00
Samuel Reed
c1a92d8520 fix: skip pending_approval agents in heartbeat tickTimers
Agents in pending_approval status are not invokable, but tickTimers only
skipped paused and terminated agents, causing enqueueWakeup to throw a
409 conflict error on every heartbeat tick for pending_approval agents.

Added pending_approval to the skip guard in tickTimers to match the
existing guard in enqueueWakeup.
2026-03-05 11:16:59 -05:00
Dotta
69b2875060 cursor adapter: use --yolo instead of --trust
The --yolo flag bypasses interactive prompts more broadly than --trust.
Updated execute, test probe, docs, and test expectations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:00:22 -06:00
Dotta
7cb46d97f6 Merge pull request #31 from tylerwince/codex/goal-detail-subgoals-render-fix
fix(ui): render sub-goals on goal detail pages
2026-03-05 09:48:52 -06:00
Dotta
e31d77bc47 Reset sessions for manual and timer heartbeat wakes 2026-03-05 09:48:11 -06:00
Dotta
90d39b9cbd Merge pull request #8 from numman-ali/fix/filter-terminated-agents-from-list-and-org
fix: exclude terminated agents from list and org chart endpoints
2026-03-05 09:46:38 -06:00
Dotta
bc68c3a504 cursor adapter: pipe prompts over stdin 2026-03-05 09:35:43 -06:00
Dotta
59bc52f527 cursor adapter: do not default to read-only ask mode 2026-03-05 09:27:20 -06:00
Dotta
d37e1d3dc3 preserve thinking delta whitespace in runlog streaming 2026-03-05 09:25:03 -06:00
Dotta
34d9122b45 Add one-command OpenClaw Docker UI smoke script 2026-03-05 09:15:46 -06:00
Dotta
1f7218640c cursor adapter: clarify paperclip env availability 2026-03-05 09:12:13 -06:00
Konan69
0078fa66a3 Use precomputed runtime env in OpenCode execute 2026-03-05 16:11:11 +01:00
Konan69
f4f9d6fd3f Fix remaining OpenCode review comments 2026-03-05 16:07:12 +01:00
Konan69
69c453b274 Address PR feedback for OpenCode integration 2026-03-05 15:52:59 +01:00
Dotta
d54ee6c4dc docs: add ClipHub marketplace spec
Product spec for ClipHub — a marketplace for Paperclip team
configurations, agent blueprints, skills, and governance templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:36:09 -06:00
Dotta
b48f0314e7 fix(opencode): pretty-print JSON tool output in run transcript
Detect JSON-like tool output and format it with indentation instead of
displaying it as a single compressed line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:36:04 -06:00
Dotta
e1b24c1d5c feat(cursor): export skill injection helper and document auto-behaviors
Export ensureCursorSkillsInjected from the server entrypoint and add
a test for skill directory injection. Document the auto-inject and
auto-trust behaviors in the adapter notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:35:59 -06:00
Dotta
1c9b7ef918 coalesce cursor thinking deltas in run log streaming 2026-03-05 08:35:00 -06:00
Dotta
8f70e79240 cursor adapter: auto-pass trust flag for non-interactive runs 2026-03-05 08:28:12 -06:00
Dotta
eabfd9d9f6 expand cursor stream-json event parsing coverage 2026-03-05 08:25:41 -06:00
Konan69
6a101e0da1 Add OpenCode provider integration and strict model selection 2026-03-05 15:24:20 +01:00
Dotta
426c1044b6 fix cursor stream-json multiplexed output handling 2026-03-05 08:07:20 -06:00
Dotta
875924a7f3 Fix OpenCode default model and model-unavailable diagnostics 2026-03-05 08:02:39 -06:00
Dotta
e835c5cee9 Fix cursor model defaults and add dynamic model discovery 2026-03-05 07:52:23 -06:00
Dotta
db54f77b73 Add retry button to run detail page for failed/timed_out runs
The retry button was only on the Inbox page but missing from the individual
run detail view. This adds it next to the status badge, matching the same
wakeup pattern used in the Inbox (retry_failed_run with context snapshot).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:50:54 -06:00
Dotta
67eb5e5734 Limit session reset to assignment wakes only 2026-03-05 07:39:30 -06:00
Dotta
758a5538c5 Improve 500 error logging with actual error details and correct log levels
pino-http was logging 500s at INFO level with a generic "failed with status
code 500" message. Now 500s log at ERROR level and include the actual error
(message, stack, name) via res.locals handoff from the error handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:39:05 -06:00
Dotta
3ae112acff Improve OpenCode auth diagnostics for model lookup failures 2026-03-05 07:29:31 -06:00
Dotta
9454f76c0c Fix issue active-run fallback to match issue context 2026-03-05 07:27:48 -06:00
Dotta
944263f44b Reset sessions for comment-triggered wakeups 2026-03-05 07:10:24 -06:00
Dotta
a1944fceab feat: add star history section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:58:16 -06:00
Dotta
8d5c9fde3b fix: use company-prefixed URLs in paperclip skill documentation
Update comment style guide and API reference to use /<prefix>/issues/...
format instead of /issues/... so agent-generated markdown links navigate
directly to the correct company-scoped route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:56:47 -06:00
Dotta
d8688bbd93 fix: apply sorting to search results on issues page
Previously, sorting was skipped when a search query was active, so
search results were only ordered by backend relevance ranking.
Now client-side sorting applies to search results too.
Also changed default sort from "created" to "updated" desc so most
recently updated issues appear first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:55:33 -06:00
Dotta
306cd65353 Reset task session on issue assignment wakes 2026-03-05 06:54:36 -06:00
Dotta
8a85173150 feat: add cursor local adapter across server ui and cli 2026-03-05 06:31:22 -06:00
Dotta
b4a02ebc3f Improve workspace fallback logging and session resume migration 2026-03-05 06:14:32 -06:00
Dotta
1f57577c54 Add green 'Recommended' badge to Claude Code and Codex in onboarding wizard
Shows a green pill badge on the Claude Code and Codex adapter cards
during CEO agent configuration in the onboarding flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:04:42 -06:00
Dotta
ec0b7daca2 Add paperclipai db:backup CLI command 2026-03-05 06:02:12 -06:00
Tyler Wince
bdc0480e62 fix(ui): render sub-goals in goal detail tree 2026-03-04 20:20:21 -07:00
Dotta
c145074daf Add configurable automatic database backup scheduling 2026-03-04 18:03:23 -06:00
Dotta
f6a09bcbea feat: add opencode local adapter support 2026-03-04 16:48:54 -06:00
Dotta
d4a2fc6464 Improve OpenClaw smoke auth error handling 2026-03-04 16:37:55 -06:00
Dotta
be50daba42 Add OpenClaw onboarding text endpoint and join smoke harness 2026-03-04 16:29:14 -06:00
Numman Ali
7b334ff2b7 fix: exclude terminated agents from list and org chart endpoints
Terminated agents (e.g. from rejected hire approvals) were visible in
GET /companies/:companyId/agents and GET /companies/:companyId/org because
list() and orgForCompany() had no status filtering.

- Add ne(agents.status, "terminated") filter to both queries
- Add optional { includeTerminated: true } param to list() for callers
  that need all agents (e.g. company-portability export with skip counting)
- orgForCompany() always excludes terminated (no escape hatch needed)

Fixes #5
2026-03-04 22:28:22 +00:00
Dotta
5bbfddf70d Update README Roadmap section with detailed items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:59:08 -06:00
Dotta
358467a506 chore: release v0.2.7 2026-03-04 14:51:33 -06:00
Dotta
59507f18ec Allow onboarding to continue after failed env test 2026-03-04 14:46:29 -06:00
Dotta
b198b4a02c fix(server): require embedded-postgres for embedded DB mode 2026-03-04 14:46:03 -06:00
Dotta
5606f76ab4 Add onboarding retry action to unset ANTHROPIC_API_KEY 2026-03-04 14:40:12 -06:00
Dotta
0542f555ba Fix markdown list markers in editor and comment rendering 2026-03-04 12:20:29 -06:00
Dotta
18c9eb7b1e Filter out archived companies from new issue company selector
Companies with status "archived" are now hidden from the company
dropdown in the NewIssueDialog, matching the expected behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:20:14 -06:00
Dotta
675e0dcff1 docs: document issue search (q= param) in Paperclip skill
The API already supports full-text search via ?q= on the issues list
endpoint. Added documentation to both SKILL.md and the API reference
so agents know they can search issues by title, identifier,
description, and comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:35:13 -06:00
Dotta
b66c6d017a Adjust docker onboard smoke defaults and console guidance 2026-03-04 10:48:36 -06:00
Dotta
bbf7490f32 Fix onboard smoke Docker flow for clean npx runs 2026-03-04 10:42:07 -06:00
Dotta
5dffdbb382 chore: release v0.2.6 2026-03-04 10:24:03 -06:00
Dotta
ea637110ac Add Ubuntu onboard smoke flow and lazy-load auth startup 2026-03-04 10:15:11 -06:00
Dotta
3ae9d95354 fix: stabilize paperclipai run server import errors 2026-03-04 10:02:23 -06:00
Dotta
a95e38485d fix: doctor command auto-creates directories and treats LLM as optional
Instead of showing alarming warnings on first run when storage, log,
and database directories don't exist, the doctor checks now silently
create them and report pass. LLM provider is treated as optional
rather than a warning when not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:58:30 -06:00
Dotta
c7c96feef7 docs: simplify quickstart to npx onboard, mention create-adapter skill
- Remove Docker option from quickstart, make `npx paperclipai onboard --yes` the recommended path
- Add tip about `create-agent-adapter` skill in the creating-an-adapter guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:11:06 -06:00
Dotta
7e387a1883 docs: update Discord invite link to https://discord.gg/m4HZY7xNG3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:54:55 -06:00
Dotta
108bb9bd15 docs: update quickstart CTA to npx paperclipai onboard --yes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:47:33 -06:00
Dotta
6141d5c3f2 chore: release v0.2.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:14:33 -06:00
Dotta
a4da932d8d fix: remove stale server/ui-dist from git add in release.sh
The release script cleaned up temporary build artifacts (ui-dist, skills)
before staging files for the commit, then tried to git add server/ui-dist
which no longer existed.  Remove it from the git add list since these
artifacts should never be committed — they're only needed during publish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:14:28 -06:00
Dotta
ab3b9ab19f chore: bump all packages to 0.2.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:10:52 -06:00
Dotta
f4a5b00116 fix: bundle skills directory into npm packages for runtime discovery
The claude-local, codex-local adapters and the server all resolve a
skills/ directory using __dirname-relative paths that only work inside
the monorepo.  When installed from npm the paths point outside the
package and cause ENOENT on readdir/readFile.

- Update both adapter execute.ts files to try a published-path
  candidate (../../skills from dist/) before falling back to the
  monorepo dev path (../../../../../skills from src/).
- Update server readSkillMarkdown() to try the published path first.
- Add "skills" to the files array in server, claude-local, and
  codex-local package.json so npm includes them.
- Update release.sh to copy the repo-root skills/ into each package
  before publish, and clean up after.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:06:12 -06:00
Dotta
09d2ef1a37 fix: restore docs deleted in v0.2.3 release, add Paperclip branding
- Restored docs/ directory that was accidentally deleted by `git add -A`
  in the v0.2.3 release script
- Replaced generic "P" favicon with actual paperclip icon using brand
  primary color (#2563EB)
- Added light/dark logo SVGs for Mintlify navbar (paperclip icon + wordmark)
- Updated docs.json with logo configuration for dark/light mode
- Fixed release.sh to stage only release-related files instead of `git add -A`
  to prevent sweeping unrelated changes into release commits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:49:43 -06:00
Dotta
d18312d6de fix: bundle UI dist into server package for npm publishing
The server's static-ui mode resolves the UI dist path relative to its
own directory. In the monorepo it finds ../../ui/dist, but when published
to npm the UI package isn't available.

- server/src/app.ts: try ../ui-dist (published) then ../../ui/dist (dev),
  gracefully degrade to API-only if neither exists
- server/package.json: include ui-dist/ in published files
- scripts/release.sh: build UI and copy dist to server/ui-dist before
  publishing, clean up in restore step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:45:45 -06:00
Dotta
28bf5e9e9b chore: release v0.2.3 2026-03-03 15:39:13 -06:00
289 changed files with 39516 additions and 2432 deletions

View File

@@ -0,0 +1,5 @@
---
"@paperclipai/shared": minor
---
Add support for Pi local adapter in constants and onboarding UI.

48
.github/workflows/pr-policy.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: PR Policy
on:
pull_request:
branches:
- master
concurrency:
group: pr-policy-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Block manual lockfile edits
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
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 }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
fi

42
.github/workflows/pr-verify.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: PR Verify
on:
pull_request:
branches:
- master
concurrency:
group: pr-verify-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build

76
.github/workflows/refresh-lockfile.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Refresh Lockfile
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: refresh-lockfile-master
cancel-in-progress: false
jobs:
refresh_and_verify:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Commit refreshed lockfile
run: |
if git diff --quiet -- pnpm-lock.yaml; then
exit 0
fi
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push || {
echo "Push failed because master moved during lockfile refresh."
echo "A later refresh run should recompute the lockfile from the newer master state."
exit 1
}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build

41
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,41 @@
# Contributing Guide
Thanks for wanting to contribute!
We really appreciate both small fixes and thoughtful larger changes.
## Two Paths to Get Your Pull Request Accepted
### Path 1: Small, Focused Changes (Fastest way to get merged)
- Pick **one** clear thing to fix/improve
- Touch the **smallest possible number of files**
- Make sure the change is very targeted and easy to review
- All automated checks pass (including Greptile comments)
- No new lint/test failures
These almost always get merged quickly when they're clean.
### Path 2: Bigger or Impactful Changes
- **First** talk about it in Discord → #dev channel
→ Describe what you're trying to solve
→ Share rough ideas / approach
- Once there's rough agreement, build it
- In your PR include:
- Before / After screenshots (or short video if UI/behavior change)
- Clear description of what & why
- Proof it works (manual testing notes)
- All tests passing
- All Greptile + other PR comments addressed
PRs that follow this path are **much** more likely to be accepted, even when they're large.
## General Rules (both paths)
- Write clear commit messages
- Keep PR title + description meaningful
- One PR = one logical change (unless it's a small related group)
- Run tests locally first
- Be kind in discussions 😄
Questions? Just ask in #dev — we're happy to help.
Happy hacking!

View File

@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim AS base
FROM node:lts-trixie-slim AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& rm -rf /var/lib/apt/lists/*
@@ -15,14 +15,18 @@ COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclip/ui build
RUN pnpm --filter @paperclip/server build
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
WORKDIR /app
@@ -37,7 +41,7 @@ ENV NODE_ENV=production \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=local_trusted \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
VOLUME ["/paperclip"]

40
Dockerfile.onboard-smoke Normal file
View File

@@ -0,0 +1,40 @@
FROM ubuntu:24.04
ARG NODE_MAJOR=20
ARG PAPERCLIPAI_VERSION=latest
ARG HOST_UID=10001
ENV DEBIAN_FRONTEND=noninteractive \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_OPEN_ON_LISTEN=false \
HOST=0.0.0.0 \
PORT=3100 \
HOME=/home/paperclip \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NPM_CONFIG_UPDATE_NOTIFIER=false \
NODE_MAJOR=${NODE_MAJOR} \
PAPERCLIPAI_VERSION=${PAPERCLIPAI_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg locales \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& locale-gen en_US.UTF-8 \
&& groupadd --gid 10001 paperclip \
&& useradd --create-home --shell /bin/bash --uid "${HOST_UID}" --gid 10001 paperclip \
&& mkdir -p /paperclip /home/paperclip/workspace \
&& chown -R paperclip:paperclip /paperclip /home/paperclip \
&& rm -rf /var/lib/apt/lists/*
VOLUME ["/paperclip"]
WORKDIR /home/paperclip/workspace
EXPOSE 3100
USER paperclip
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Paperclip AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -6,13 +6,13 @@
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/paperclip"><strong>Discord</strong></a>
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/paperclip"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
</p>
<br/>
@@ -174,7 +174,7 @@ Paperclip handles the hard orchestration details correctly.
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard
npx paperclipai onboard --yes
```
Or manually:
@@ -218,7 +218,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
## Development
```bash
pnpm dev # Full dev (API + UI)
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
@@ -233,9 +234,13 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
## Roadmap
- 🛒 **Clipmart** — Download and share entire company architectures
- 🧩 **Plugin System** — Embed custom plugins (e.g. Reporting, Knowledge Base) into Paperclip
- ☁️ **Cloud Agent Adapters** — Add more adapters for cloud-hosted agents
- ⚪ 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
<br/>
@@ -249,7 +254,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
## Community
- [Discord](#) — Coming soon
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
@@ -259,6 +264,10 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
MIT &copy; 2026 Paperclip
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<br/>
---

View File

@@ -1,5 +1,75 @@
# paperclipai
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.7
- @paperclipai/adapter-utils@0.2.7
- @paperclipai/db@0.2.7
- @paperclipai/adapter-claude-local@0.2.7
- @paperclipai/adapter-codex-local@0.2.7
- @paperclipai/adapter-openclaw@0.2.7
- @paperclipai/server@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.6
- @paperclipai/adapter-utils@0.2.6
- @paperclipai/db@0.2.6
- @paperclipai/adapter-claude-local@0.2.6
- @paperclipai/adapter-codex-local@0.2.6
- @paperclipai/adapter-openclaw@0.2.6
- @paperclipai/server@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.5
- @paperclipai/adapter-utils@0.2.5
- @paperclipai/db@0.2.5
- @paperclipai/adapter-claude-local@0.2.5
- @paperclipai/adapter-codex-local@0.2.5
- @paperclipai/adapter-openclaw@0.2.5
- @paperclipai/server@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.4
- @paperclipai/adapter-utils@0.2.4
- @paperclipai/db@0.2.4
- @paperclipai/adapter-claude-local@0.2.4
- @paperclipai/adapter-codex-local@0.2.4
- @paperclipai/adapter-openclaw@0.2.4
- @paperclipai/server@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.3
- @paperclipai/adapter-utils@0.2.3
- @paperclipai/db@0.2.3
- @paperclipai/adapter-claude-local@0.2.3
- @paperclipai/adapter-codex-local@0.2.3
- @paperclipai/adapter-openclaw@0.2.3
- @paperclipai/server@0.2.3
## 0.2.2
### Patch Changes

View File

@@ -21,7 +21,7 @@ const workspacePaths = [
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/openclaw",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that should NOT be bundled — they'll be published

View File

@@ -1,6 +1,6 @@
{
"name": "paperclipai",
"version": "0.2.2",
"version": "0.2.7",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
@@ -36,7 +36,10 @@
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",

View File

@@ -21,6 +21,12 @@ function writeBaseConfig(configPath: string) {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip-db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip-backups",
},
},
logging: {
mode: "file",
@@ -68,4 +74,3 @@ describe("allowed-hostname command", () => {
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
});
});

View File

@@ -1,7 +1,10 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js";
@@ -15,13 +18,37 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printCodexStreamEvent,
};
const openclawCLIAdapter: CLIAdapterModule = {
type: "openclaw",
formatStdoutEvent: printOpenClawStreamEvent,
const openCodeLocalCLIAdapter: CLIAdapterModule = {
type: "opencode_local",
formatStdoutEvent: printOpenCodeStreamEvent,
};
const piLocalCLIAdapter: CLIAdapterModule = {
type: "pi_local",
formatStdoutEvent: printPiStreamEvent,
};
const cursorLocalCLIAdapter: CLIAdapterModule = {
type: "cursor",
formatStdoutEvent: printCursorStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
};
const adaptersByType = new Map<string, CLIAdapterModule>(
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
[
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
].map((a) => [a.type, a]),
);
export function getCLIAdapter(type: string): CLIAdapterModule {

View File

@@ -39,15 +39,7 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
const reportedPath = dataDir;
if (!fs.existsSync(dataDir)) {
return {
name: "Database",
status: "warn",
message: `Embedded PostgreSQL data directory does not exist: ${reportedPath}`,
canRepair: true,
repair: () => {
fs.mkdirSync(reportedPath, { recursive: true });
},
};
fs.mkdirSync(reportedPath, { recursive: true });
}
return {

View File

@@ -5,20 +5,16 @@ export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
if (!config.llm) {
return {
name: "LLM provider",
status: "warn",
message: "No LLM provider configured",
canRepair: false,
repairHint: "Run `paperclipai configure --section llm` to set one up",
status: "pass",
message: "No LLM provider configured (optional)",
};
}
if (!config.llm.apiKey) {
return {
name: "LLM provider",
status: "warn",
message: `${config.llm.provider} configured but no API key set`,
canRepair: false,
repairHint: "Run `paperclipai configure --section llm`",
status: "pass",
message: `${config.llm.provider} configured but no API key set (optional)`,
};
}

View File

@@ -8,15 +8,7 @@ export function logCheck(config: PaperclipConfig, configPath?: string): CheckRes
const reportedDir = logDir;
if (!fs.existsSync(logDir)) {
return {
name: "Log directory",
status: "warn",
message: `Log directory does not exist: ${reportedDir}`,
canRepair: true,
repair: () => {
fs.mkdirSync(reportedDir, { recursive: true });
},
};
fs.mkdirSync(reportedDir, { recursive: true });
}
try {

View File

@@ -7,16 +7,7 @@ export function storageCheck(config: PaperclipConfig, configPath?: string): Chec
if (config.storage.provider === "local_disk") {
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
if (!fs.existsSync(baseDir)) {
return {
name: "Storage",
status: "warn",
message: `Local storage directory does not exist: ${baseDir}`,
canRepair: true,
repair: () => {
fs.mkdirSync(baseDir, { recursive: true });
},
repairHint: "Run with --repair to create local storage directory",
};
fs.mkdirSync(baseDir, { recursive: true });
}
try {

View File

@@ -28,6 +28,12 @@ function resolveDbUrl(configPath?: string) {
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
const fromEnv =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL;
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
const config = readConfig(configPath);
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");

View File

@@ -1,5 +1,9 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
formatInlineRecord,
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
companyId?: string;
}
interface AgentLocalCliOptions extends BaseClientOptions {
companyId?: string;
keyName?: string;
installSkills?: boolean;
}
interface CreatedAgentKey {
id: string;
name: string;
token: string;
createdAt: string;
}
interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
path.resolve(process.cwd(), "skills"),
];
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
return path.join(base, "skills");
}
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
summary.skipped.push(entry.name);
continue;
}
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
} catch (err) {
summary.failed.push({
name: entry.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return summary;
}
function buildAgentEnvExports(input: {
apiBase: string;
companyId: string;
agentId: string;
apiKey: string;
}): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
return [
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
].join("\n");
}
export function registerAgentCommands(program: Command): void {
const agent = program.command("agent").description("Agent operations");
@@ -71,4 +176,102 @@ export function registerAgentCommands(program: Command): void {
}
}),
);
addCommonClientOptions(
agent
.command("local-cli")
.description(
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
)
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
const agentRow = await ctx.api.get<Agent>(
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
);
if (!agentRow) {
throw new Error(`Agent not found: ${agentRef}`);
}
const now = new Date().toISOString().replaceAll(":", "-");
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
if (!key) {
throw new Error("Failed to create API key");
}
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
);
}
installSummaries.push(
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
const exportsText = buildAgentEnvExports({
apiBase: ctx.api.apiBase,
companyId: agentRow.companyId,
agentId: agentRow.id,
apiKey: key.token,
});
if (ctx.json) {
printOutput(
{
agent: {
id: agentRow.id,
name: agentRow.name,
urlKey: agentRow.urlKey,
companyId: agentRow.companyId,
},
key: {
id: key.id,
name: key.name,
createdAt: key.createdAt,
token: key.token,
},
skills: installSummaries,
exports: exportsText,
},
{ json: true },
);
return;
}
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
console.log(`API key created: ${key.name} (${key.id})`);
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);
}
}
}
console.log("");
console.log("# Run this in your shell before launching codex/claude:");
console.log(exportsText);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}

View File

@@ -10,6 +10,7 @@ import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
@@ -39,6 +40,12 @@ function defaultConfig(): PaperclipConfig {
mode: "embedded-postgres",
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: resolveDefaultBackupDir(instanceId),
},
},
logging: {
mode: "file",
@@ -118,7 +125,7 @@ export async function configure(opts: {
switch (section) {
case "database":
config.database = await promptDatabase();
config.database = await promptDatabase(config.database);
break;
case "llm": {
const llm = await promptLlm();

View File

@@ -0,0 +1,102 @@
import path from "node:path";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { formatDatabaseBackupResult, runDatabaseBackup } from "@paperclipai/db";
import {
expandHomePrefix,
resolveDefaultBackupDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
type DbBackupOptions = {
config?: string;
dir?: string;
retentionDays?: number;
filenamePrefix?: string;
json?: boolean;
};
function resolveConnectionString(configPath?: string): { value: string; source: string } {
const envUrl = process.env.DATABASE_URL?.trim();
if (envUrl) return { value: envUrl, source: "DATABASE_URL" };
const config = readConfig(configPath);
if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) {
return { value: config.database.connectionString.trim(), source: "config.database.connectionString" };
}
const port = config?.database.embeddedPostgresPort ?? 54329;
return {
value: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
source: `embedded-postgres@${port}`,
};
}
function normalizeRetentionDays(value: number | undefined, fallback: number): number {
const candidate = value ?? fallback;
if (!Number.isInteger(candidate) || candidate < 1) {
throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`);
}
return candidate;
}
function resolveBackupDir(raw: string): string {
return path.resolve(expandHomePrefix(raw.trim()));
}
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
const configPath = resolveConfigPath(opts.config);
const config = readConfig(opts.config);
const connection = resolveConnectionString(opts.config);
const defaultDir = resolveDefaultBackupDir(resolvePaperclipInstanceId());
const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir;
const backupDir = resolveBackupDir(configuredDir);
const retentionDays = normalizeRetentionDays(
opts.retentionDays,
config?.database.backup.retentionDays ?? 30,
);
const filenamePrefix = opts.filenamePrefix?.trim() || "paperclip";
p.log.message(pc.dim(`Config: ${configPath}`));
p.log.message(pc.dim(`Connection source: ${connection.source}`));
p.log.message(pc.dim(`Backup dir: ${backupDir}`));
p.log.message(pc.dim(`Retention: ${retentionDays} day(s)`));
const spinner = p.spinner();
spinner.start("Creating database backup...");
try {
const result = await runDatabaseBackup({
connectionString: connection.value,
backupDir,
retentionDays,
filenamePrefix,
});
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
if (opts.json) {
console.log(
JSON.stringify(
{
backupFile: result.backupFile,
sizeBytes: result.sizeBytes,
prunedCount: result.prunedCount,
backupDir,
retentionDays,
connectionSource: connection.source,
},
null,
2,
),
);
}
p.outro(pc.green("Backup completed."));
} catch (err) {
spinner.stop(pc.red("Backup failed."));
throw err;
}
}

View File

@@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? "";
const databaseMode = config?.database?.mode ?? "embedded-postgres";
const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing";
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
config?.auth?.publicBaseUrl ??
"";
const publicUrlSource: EnvSource =
process.env.PAPERCLIP_PUBLIC_URL
? "env"
: process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL
? "env"
: config?.auth?.publicBaseUrl
? "config"
: "missing";
let trustedOriginsDefault = "";
if (publicUrl) {
try {
trustedOriginsDefault = new URL(publicUrl).origin;
} catch {
trustedOriginsDefault = "";
}
}
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
@@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
required: false,
note: "HTTP listen port",
},
{
key: "PAPERCLIP_PUBLIC_URL",
value: publicUrl,
source: publicUrlSource,
required: false,
note: "Canonical public URL for auth/callback/invite origin wiring",
},
{
key: "BETTER_AUTH_TRUSTED_ORIGINS",
value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault,
source: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? "env"
: trustedOriginsDefault
? "default"
: "missing",
required: false,
note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)",
},
{
key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS",
value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS,

View File

@@ -1,5 +1,18 @@
import * as p from "@clack/prompts";
import path from "node:path";
import pc from "picocolors";
import {
AUTH_BASE_URL_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
type SecretProvider,
type StorageProvider,
} from "@paperclipai/shared";
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
@@ -12,6 +25,8 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import {
describeLocalInstancePaths,
expandHomePrefix,
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
@@ -28,32 +43,189 @@ type OnboardOptions = {
invokedByRun?: boolean;
};
function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets"> {
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
const ONBOARD_ENV_KEYS = [
"PAPERCLIP_PUBLIC_URL",
"DATABASE_URL",
"PAPERCLIP_DB_BACKUP_ENABLED",
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
"PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
"PAPERCLIP_DB_BACKUP_DIR",
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"HOST",
"PORT",
"SERVE_UI",
"PAPERCLIP_ALLOWED_HOSTNAMES",
"PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
"PAPERCLIP_STORAGE_PROVIDER",
"PAPERCLIP_STORAGE_LOCAL_DIR",
"PAPERCLIP_STORAGE_S3_BUCKET",
"PAPERCLIP_STORAGE_S3_REGION",
"PAPERCLIP_STORAGE_S3_ENDPOINT",
"PAPERCLIP_STORAGE_S3_PREFIX",
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
"PAPERCLIP_SECRETS_PROVIDER",
"PAPERCLIP_SECRETS_STRICT_MODE",
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
] as const;
function parseBooleanFromEnv(rawValue: string | undefined): boolean | null {
if (rawValue === undefined) return null;
const lower = rawValue.trim().toLowerCase();
if (lower === "true" || lower === "1" || lower === "yes") return true;
if (lower === "false" || lower === "0" || lower === "no") return false;
return null;
}
function parseNumberFromEnv(rawValue: string | undefined): number | null {
if (!rawValue) return null;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function parseEnumFromEnv<T extends string>(rawValue: string | undefined, allowedValues: readonly T[]): T | null {
if (!rawValue) return null;
return allowedValues.includes(rawValue as T) ? (rawValue as T) : null;
}
function resolvePathFromEnv(rawValue: string | undefined): string | null {
if (!rawValue || rawValue.trim().length === 0) return null;
return path.resolve(expandHomePrefix(rawValue.trim()));
}
function quickstartDefaultsFromEnv(): {
defaults: OnboardDefaults;
usedEnvKeys: string[];
ignoredEnvKeys: Array<{ key: string; reason: string }>;
} {
const instanceId = resolvePaperclipInstanceId();
return {
const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined;
const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
DEPLOYMENT_EXPOSURES,
);
const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const authPublicBaseUrl = publicUrl;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
AUTH_BASE_URL_MODES,
);
const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto");
const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES
? process.env.PAPERCLIP_ALLOWED_HOSTNAMES
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0)
: [];
const hostnameFromPublicUrl = publicUrl
? (() => {
try {
return new URL(publicUrl).hostname.trim().toLowerCase();
} catch {
return null;
}
})()
: null;
const storageProvider =
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
defaultStorage.provider;
const secretsProvider =
parseEnumFromEnv<SecretProvider>(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ??
defaultSecrets.provider;
const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true;
const databaseBackupIntervalMinutes = Math.max(
1,
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60,
);
const databaseBackupRetentionDays = Math.max(
1,
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30,
);
const defaults: OnboardDefaults = {
database: {
mode: "embedded-postgres",
mode: databaseUrl ? "postgres" : "embedded-postgres",
...(databaseUrl ? { connectionString: databaseUrl } : {}),
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
embeddedPostgresPort: 54329,
backup: {
enabled: databaseBackupEnabled,
intervalMinutes: databaseBackupIntervalMinutes,
retentionDays: databaseBackupRetentionDays,
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
},
},
logging: {
mode: "file",
logDir: resolveDefaultLogsDir(instanceId),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
deploymentMode,
exposure: deploymentExposure,
host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT) || 3100,
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
},
auth: {
baseUrlMode: "auto",
baseUrlMode: authBaseUrlMode,
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
},
storage: {
provider: storageProvider,
localDisk: {
baseDir:
resolvePathFromEnv(process.env.PAPERCLIP_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir,
},
s3: {
bucket: process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket,
region: process.env.PAPERCLIP_STORAGE_S3_REGION ?? defaultStorage.s3.region,
endpoint: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint,
prefix: process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix,
forcePathStyle:
parseBooleanFromEnv(process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE) ??
defaultStorage.s3.forcePathStyle,
},
},
secrets: {
provider: secretsProvider,
strictMode: parseBooleanFromEnv(process.env.PAPERCLIP_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode,
localEncrypted: {
keyFilePath:
resolvePathFromEnv(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
defaultSecrets.localEncrypted.keyFilePath,
},
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),
};
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
reason: "Ignored because deployment mode local_trusted always forces private exposure",
});
}
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
(key) => process.env[key] !== undefined && !ignoredKeySet.has(key),
);
return { defaults, usedEnvKeys, ignoredEnvKeys };
}
export async function onboard(opts: OnboardOptions): Promise<void> {
@@ -109,6 +281,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
let {
database,
logging,
@@ -116,11 +289,11 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
auth,
storage,
secrets,
} = quickstartDefaults();
} = derivedDefaults;
if (setupMode === "advanced") {
p.log.step(pc.bold("Database"));
database = await promptDatabase();
database = await promptDatabase(database);
if (database.mode === "postgres" && database.connectionString) {
const s = p.spinner();
@@ -184,13 +357,20 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
logging = await promptLogging();
p.log.step(pc.bold("Server"));
({ server, auth } = await promptServer());
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
p.log.step(pc.bold("Storage"));
storage = await promptStorage(defaultStorageConfig());
storage = await promptStorage(storage);
p.log.step(pc.bold("Secrets"));
secrets = defaultSecretsConfig();
const secretsDefaults = defaultSecretsConfig();
secrets = {
provider: secrets.provider ?? secretsDefaults.provider,
strictMode: secrets.strictMode ?? secretsDefaults.strictMode,
localEncrypted: {
keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath,
},
};
p.log.message(
pc.dim(
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
@@ -198,9 +378,17 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
} else {
p.log.step(pc.bold("Quickstart"));
p.log.message(
pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."),
);
p.log.message(pc.dim("Using quickstart defaults."));
if (usedEnvKeys.length > 0) {
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
} else {
p.log.message(
pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."),
);
}
for (const ignored of ignoredEnvKeys) {
p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`));
}
}
const jwtSecret = ensureAgentJwtSecret(configPath);

View File

@@ -84,6 +84,15 @@ function isModuleNotFoundError(err: unknown): boolean {
return err.message.includes("Cannot find module");
}
function getMissingModuleSpecifier(err: unknown): string | null {
if (!(err instanceof Error)) return null;
const packageMatch = err.message.match(/Cannot find package '([^']+)' imported from/);
if (packageMatch?.[1]) return packageMatch[1];
const moduleMatch = err.message.match(/Cannot find module '([^']+)'/);
if (moduleMatch?.[1]) return moduleMatch[1];
return null;
}
function maybeEnableUiDevMiddleware(entrypoint: string): void {
if (process.env.PAPERCLIP_UI_DEV_MIDDLEWARE !== undefined) return;
const normalized = entrypoint.replaceAll("\\", "/");
@@ -106,7 +115,9 @@ async function importServerEntry(): Promise<void> {
try {
await import("@paperclipai/server");
} catch (err) {
if (isModuleNotFoundError(err)) {
const missingSpecifier = getMissingModuleSpecifier(err);
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
if (isModuleNotFoundError(err) && missingServerEntrypoint) {
throw new Error(
`Could not locate a Paperclip server entrypoint.\n` +
`Tried: ${devEntry}, @paperclipai/server\n` +

View File

@@ -49,6 +49,10 @@ export function resolveDefaultStorageDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
}
export function resolveDefaultBackupDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
}
export function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
@@ -64,6 +68,7 @@ export function describeLocalInstancePaths(instanceId?: string) {
instanceRoot,
configPath: resolveDefaultConfigPath(resolvedInstanceId),
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
backupDir: resolveDefaultBackupDir(resolvedInstanceId),
logDir: resolveDefaultLogsDir(resolvedInstanceId),
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
storageDir: resolveDefaultStorageDir(resolvedInstanceId),

View File

@@ -2,6 +2,7 @@ export {
paperclipConfigSchema,
configMetaSchema,
llmConfigSchema,
databaseBackupConfigSchema,
databaseConfigSchema,
loggingConfigSchema,
serverConfigSchema,
@@ -13,6 +14,7 @@ export {
secretsLocalEncryptedConfigSchema,
type PaperclipConfig,
type LlmConfig,
type DatabaseBackupConfig,
type DatabaseConfig,
type LoggingConfig,
type ServerConfig,

View File

@@ -7,6 +7,7 @@ import { addAllowedHostname } from "./commands/allowed-hostname.js";
import { heartbeatRun } from "./commands/heartbeat-run.js";
import { runCommand } from "./commands/run.js";
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
import { dbBackupCommand } from "./commands/db-backup.js";
import { registerContextCommands } from "./commands/client/context.js";
import { registerCompanyCommands } from "./commands/client/company.js";
import { registerIssueCommands } from "./commands/client/issue.js";
@@ -23,7 +24,7 @@ const DATA_DIR_OPTION_HELP =
program
.name("paperclipai")
.description("Paperclip CLI — setup, diagnose, and configure your instance")
.version("0.2.2");
.version("0.2.7");
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
@@ -70,6 +71,19 @@ program
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
.action(configure);
program
.command("db:backup")
.description("Create a one-off database backup using current config")
.option("-c, --config <path>", "Path to config file")
.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("--json", "Print backup metadata as JSON")
.action(async (opts) => {
await dbBackupCommand(opts);
});
program
.command("allowed-hostname")
.description("Allow a hostname for authenticated/private mode access")

View File

@@ -1,9 +1,26 @@
import * as p from "@clack/prompts";
import type { DatabaseConfig } from "../config/schema.js";
import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipInstanceId } from "../config/home.js";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
export async function promptDatabase(): Promise<DatabaseConfig> {
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(resolvePaperclipInstanceId());
export async function promptDatabase(current?: DatabaseConfig): Promise<DatabaseConfig> {
const instanceId = resolvePaperclipInstanceId();
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(instanceId);
const defaultBackupDir = resolveDefaultBackupDir(instanceId);
const base: DatabaseConfig = current ?? {
mode: "embedded-postgres",
embeddedPostgresDataDir: defaultEmbeddedDir,
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: defaultBackupDir,
},
};
const mode = await p.select({
message: "Database mode",
@@ -11,6 +28,7 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
{ value: "embedded-postgres" as const, label: "Embedded PostgreSQL (managed locally)", hint: "recommended" },
{ value: "postgres" as const, label: "PostgreSQL (external server)" },
],
initialValue: base.mode,
});
if (p.isCancel(mode)) {
@@ -18,9 +36,14 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
process.exit(0);
}
let connectionString: string | undefined = base.connectionString;
let embeddedPostgresDataDir = base.embeddedPostgresDataDir || defaultEmbeddedDir;
let embeddedPostgresPort = base.embeddedPostgresPort || 54329;
if (mode === "postgres") {
const connectionString = await p.text({
const value = await p.text({
message: "PostgreSQL connection string",
defaultValue: base.connectionString ?? "",
placeholder: "postgres://user:pass@localhost:5432/paperclip",
validate: (val) => {
if (!val) return "Connection string is required for PostgreSQL mode";
@@ -28,48 +51,107 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
},
});
if (p.isCancel(connectionString)) {
if (p.isCancel(value)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return {
mode: "postgres",
connectionString,
embeddedPostgresDataDir: defaultEmbeddedDir,
embeddedPostgresPort: 54329,
};
connectionString = value;
} else {
const dataDir = await p.text({
message: "Embedded PostgreSQL data directory",
defaultValue: base.embeddedPostgresDataDir || defaultEmbeddedDir,
placeholder: defaultEmbeddedDir,
});
if (p.isCancel(dataDir)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
embeddedPostgresDataDir = dataDir || defaultEmbeddedDir;
const portValue = await p.text({
message: "Embedded PostgreSQL port",
defaultValue: String(base.embeddedPostgresPort || 54329),
placeholder: "54329",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
},
});
if (p.isCancel(portValue)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
embeddedPostgresPort = Number(portValue || "54329");
connectionString = undefined;
}
const embeddedPostgresDataDir = await p.text({
message: "Embedded PostgreSQL data directory",
defaultValue: defaultEmbeddedDir,
placeholder: defaultEmbeddedDir,
const backupEnabled = await p.confirm({
message: "Enable automatic database backups?",
initialValue: base.backup.enabled,
});
if (p.isCancel(embeddedPostgresDataDir)) {
if (p.isCancel(backupEnabled)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const embeddedPostgresPort = await p.text({
message: "Embedded PostgreSQL port",
defaultValue: "54329",
placeholder: "54329",
const backupDirInput = await p.text({
message: "Backup directory",
defaultValue: base.backup.dir || defaultBackupDir,
placeholder: defaultBackupDir,
validate: (val) => (!val || val.trim().length === 0 ? "Backup directory is required" : undefined),
});
if (p.isCancel(backupDirInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const backupIntervalInput = await p.text({
message: "Backup interval (minutes)",
defaultValue: String(base.backup.intervalMinutes || 60),
placeholder: "60",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
if (!Number.isInteger(n) || n < 1) return "Interval must be a positive integer";
if (n > 10080) return "Interval must be 10080 minutes (7 days) or less";
return undefined;
},
});
if (p.isCancel(backupIntervalInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (p.isCancel(embeddedPostgresPort)) {
const backupRetentionInput = await p.text({
message: "Backup retention (days)",
defaultValue: String(base.backup.retentionDays || 30),
placeholder: "30",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1) return "Retention must be a positive integer";
if (n > 3650) return "Retention must be 3650 days or less";
return undefined;
},
});
if (p.isCancel(backupRetentionInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return {
mode: "embedded-postgres",
embeddedPostgresDataDir: embeddedPostgresDataDir || defaultEmbeddedDir,
embeddedPostgresPort: Number(embeddedPostgresPort || "54329"),
mode,
connectionString,
embeddedPostgresDataDir,
embeddedPostgresPort,
backup: {
enabled: backupEnabled,
intervalMinutes: Number(backupIntervalInput || "60"),
retentionDays: Number(backupRetentionInput || "30"),
dir: backupDirInput || defaultBackupDir,
},
};
}

View File

@@ -149,7 +149,14 @@ export async function promptServer(opts?: {
}
return {
server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true },
server: {
deploymentMode,
exposure,
host: hostStr.trim(),
port,
allowedHostnames,
serveUi: currentServer?.serveUi ?? true,
},
auth,
};
}

View File

@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
```sh
pnpm paperclipai agent list --company-id <company-id>
pnpm paperclipai agent get <agent-id>
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
```
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
- creates a new long-lived agent API key
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
Example for shortname-based local setup:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
## Approval Commands

View File

@@ -15,6 +15,14 @@ Current implementation status:
- Node.js 20+
- pnpm 9+
## Dependency Lockfile Policy
GitHub Actions owns `pnpm-lock.yaml`.
- Do not commit `pnpm-lock.yaml` in pull requests.
- Pull request CI validates dependency resolution when manifests change.
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
## Start Dev
From repo root:
@@ -29,6 +37,8 @@ This starts:
- API server: `http://localhost:3100`
- UI: served by the API server in dev middleware mode (same origin as API)
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
Tailscale/private-auth dev mode:
```sh
@@ -141,6 +151,36 @@ pnpm dev
If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL.
## Automatic DB Backups
Paperclip can run automatic DB backups on a timer. Defaults:
- enabled
- every 60 minutes
- retain 30 days
- backup dir: `~/.paperclip/instances/default/data/backups`
Configure these in:
```sh
pnpm paperclipai configure --section database
```
Run a one-off backup manually:
```sh
pnpm paperclipai db:backup
# or:
pnpm db:backup
```
Environment overrides:
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
- `PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=<minutes>`
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
## Secrets in Dev
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
@@ -216,5 +256,61 @@ Agent-oriented invite onboarding now exposes machine-readable API docs:
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates.
- `GET /api/skills/index` lists available skill documents.
- `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown.
## OpenClaw Join Smoke Test
Run the end-to-end OpenClaw join smoke harness:
```sh
pnpm smoke:openclaw-join
```
What it validates:
- invite creation for agent-only join
- agent join request using `adapterType=openclaw`
- board approval + one-time API key claim semantics
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
Required permissions:
- This script performs board-governed actions (create invite, approve join, wakeup another agent).
- In authenticated mode, run with board auth via `PAPERCLIP_AUTH_HEADER` or `PAPERCLIP_COOKIE`.
Optional auth flags (for authenticated mode):
- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`)
- `PAPERCLIP_COOKIE` (session cookie header value)
## OpenClaw Docker UI One-Command Script
To boot OpenClaw in Docker and print a host-browser dashboard URL in one command:
```sh
pnpm smoke:openclaw-docker-ui
```
This script lives at `scripts/smoke/openclaw-docker-ui.sh` and automates clone/build/config/start for Compose-based local OpenClaw UI testing.
Pairing behavior for this smoke script:
- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required)
- set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing
Model behavior for this smoke script:
- defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default
State behavior for this smoke script:
- defaults to isolated config dir `~/.openclaw-paperclip-smoke`
- resets smoke agent state each run by default (`OPENCLAW_RESET_STATE=1`) to avoid stale provider/auth drift
Networking behavior for this smoke script:
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip

View File

@@ -42,6 +42,32 @@ Optional overrides:
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
```
If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows.
## Authenticated Compose (Single Public URL)
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
```yaml
services:
paperclip:
environment:
PAPERCLIP_DEPLOYMENT_MODE: authenticated
PAPERCLIP_DEPLOYMENT_EXPOSURE: private
PAPERCLIP_PUBLIC_URL: https://desk.koker.net
```
`PAPERCLIP_PUBLIC_URL` is used as the primary source for:
- auth public base URL
- Better Auth base URL defaults
- bootstrap invite URL defaults
- hostname allowlist defaults (hostname extracted from URL)
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
## Claude + Codex Local Adapters in Docker
The image pre-installs:
@@ -66,3 +92,35 @@ Notes:
- Without API keys, the app still runs normally.
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
## Onboard Smoke Test (Ubuntu + npm only)
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
- `npx paperclipai onboard --yes` completes
- the server binds to `0.0.0.0:3100` so host access works
- onboard/run banners and startup logs are visible in your terminal
Build + run:
```sh
./scripts/docker-onboard-smoke.sh
```
Open: `http://localhost:3131` (default smoke host port)
Useful overrides:
```sh
HOST_PORT=3200 PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_DEPLOYMENT_EXPOSURE=private ./scripts/docker-onboard-smoke.sh
```
Notes:
- Persistent data is mounted at `./data/docker-onboard-smoke` by default.
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
- The image definition is in `Dockerfile.onboard-smoke`.

View File

@@ -0,0 +1,94 @@
Use this exact checklist.
1. Start Paperclip in auth mode.
```bash
cd <paperclip-repo-root>
pnpm dev --tailscale-auth
```
Then verify:
```bash
curl -sS http://127.0.0.1:3100/api/health | jq
```
2. Start a clean/stock OpenClaw Docker.
```bash
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
```
Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
4. Use the OpenClaw invite prompt flow.
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
- Copy the generated prompt from `OpenClaw Invite Prompt`.
- Paste it into OpenClaw main chat as one message.
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
Security/control note:
- The OpenClaw invite prompt is created from a controlled endpoint:
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
- board users with invite permission can call it
- agent callers are limited to the company CEO agent
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
6. Gateway preflight (required before task tests).
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
- Confirm gateway URL is `ws://...` or `wss://...`.
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
- Confirm pairing mode is explicit:
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
- do not rely on `disableDeviceAuth` for normal onboarding
- If you can run API checks with board auth:
```bash
AGENT_ID="<newly-created-agent-id>"
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}'
```
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
Pairing handshake note:
- Clean run expectation: first task should succeed without manual pairing commands.
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
- Approve it in OpenClaw, then retry the task.
- For local docker smoke, you can approve from host:
```bash
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"'
```
- You can inspect pending vs paired devices:
```bash
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"'
```
7. Case A (manual issue test).
- Create an issue assigned to the OpenClaw agent.
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
- Verify in UI: issue status becomes `done` and comment exists.
8. Case B (message tool test).
- Create another issue assigned to OpenClaw.
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
- Verify both:
- marker comment on issue
- marker text appears in OpenClaw main chat
9. Case C (new session memory/skills test).
- In OpenClaw, start `/new` session.
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
- Verify in Paperclip UI that new issue exists.
10. Watch logs during test (optional but helpful):
```bash
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
```
11. Expected pass criteria.
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path).
- Case A: `done` + marker comment.
- Case B: `done` + marker comment + main-chat message visible.
- Case C: original task done and new issue created from `/new` session.
If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI.

1617
doc/plugins/PLUGIN_SPEC.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,5 +10,9 @@ services:
PAPERCLIP_HOME: "/paperclip"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes:
- "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip"

View File

@@ -5,6 +5,11 @@ services:
POSTGRES_USER: paperclip
POSTGRES_PASSWORD: paperclip
POSTGRES_DB: paperclip
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"]
interval: 2s
timeout: 5s
retries: 30
ports:
- "5432:5432"
volumes:
@@ -18,8 +23,16 @@ services:
DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip
PORT: "3100"
SERVE_UI: "true"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes:
- paperclip-data:/paperclip
depends_on:
- db
db:
condition: service_healthy
volumes:
pgdata:
paperclip-data:

View File

@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY server.mjs /app/server.mjs
EXPOSE 8787
CMD ["node", "/app/server.mjs"]

View File

@@ -0,0 +1,103 @@
import http from "node:http";
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook";
const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || "";
const maxBodyBytes = 1_000_000;
const maxEvents = 200;
const events = [];
let nextId = 1;
function writeJson(res, status, payload) {
res.statusCode = status;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(payload));
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > maxBodyBytes) {
reject(new Error("payload_too_large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
function trimEvents() {
if (events.length <= maxEvents) return;
events.splice(0, events.length - maxEvents);
}
const server = http.createServer(async (req, res) => {
const method = req.method ?? "GET";
const url = req.url ?? "/";
if (method === "GET" && url === "/health") {
writeJson(res, 200, { ok: true, webhookPath, events: events.length });
return;
}
if (method === "GET" && url === "/events") {
writeJson(res, 200, { count: events.length, events });
return;
}
if (method === "POST" && url === "/reset") {
events.length = 0;
writeJson(res, 200, { ok: true });
return;
}
if (method === "POST" && url === webhookPath) {
const authorization = req.headers.authorization ?? "";
if (expectedAuthHeader && authorization !== expectedAuthHeader) {
writeJson(res, 401, { error: "unauthorized" });
return;
}
try {
const raw = await readBody(req);
let body = null;
try {
body = raw.length > 0 ? JSON.parse(raw) : null;
} catch {
body = { raw };
}
const event = {
id: `evt-${nextId++}`,
receivedAt: new Date().toISOString(),
method,
path: url,
authorizationPresent: Boolean(authorization),
body,
};
events.push(event);
trimEvents();
writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length });
} catch (err) {
const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500;
writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" });
}
return;
}
writeJson(res, 404, { error: "not_found" });
});
server.listen(port, "0.0.0.0", () => {
// eslint-disable-next-line no-console
console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`);
});

View File

@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
```sh
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
Use the "Test Environment" button in the UI to validate the adapter config. It checks:

View File

@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
```
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
The environment test checks:

View File

@@ -5,6 +5,10 @@ summary: Guide to building a custom adapter
Build a custom adapter to connect Paperclip to any agent runtime.
<Tip>
If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
</Tip>
## Package Structure
```

View File

@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
@@ -52,7 +54,7 @@ Three registries consume these modules:
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local` or `codex_local`
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
- **Need to run a script or command?** Use `process`
- **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)

View File

@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
Returns the full organizational tree for the company.
## List Adapter Models
```
GET /api/companies/{companyId}/adapters/{adapterType}/models
```
Returns selectable models for an adapter type.
- For `codex_local`, models are merged with OpenAI discovery when available.
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
## Config Revisions
```

View File

@@ -48,12 +48,20 @@ pnpm dev --tailscale-auth
This binds the server to `0.0.0.0` for private-network access.
Alias:
```sh
pnpm dev --authenticated-private
```
Allow additional private hostnames:
```sh
pnpm paperclipai allowed-hostname dotta-macbook-pro
```
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
## Health Checks
```sh

View File

@@ -0,0 +1,77 @@
---
title: Tailscale Private Access
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
---
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
## 1. Start Paperclip in private authenticated mode
```sh
pnpm dev --tailscale-auth
```
This configures:
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
- `HOST=0.0.0.0` (bind on all interfaces)
Equivalent flag:
```sh
pnpm dev --authenticated-private
```
## 2. Find your reachable Tailscale address
From the machine running Paperclip:
```sh
tailscale ip -4
```
You can also use your Tailscale MagicDNS hostname (for example `my-macbook.tailnet.ts.net`).
## 3. Open Paperclip from another device
Use the Tailscale IP or MagicDNS host with the Paperclip port:
```txt
http://<tailscale-host-or-ip>:3100
```
Example:
```txt
http://my-macbook.tailnet.ts.net:3100
```
## 4. Allow custom private hostnames when needed
If you access Paperclip with a custom private hostname, add it to the allowlist:
```sh
pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net
```
## 5. Verify the server is reachable
From a remote Tailscale-connected device:
```sh
curl http://<tailscale-host-or-ip>:3100/api/health
```
Expected result:
```json
{"status":"ok"}
```
## Troubleshooting
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.

View File

@@ -9,6 +9,10 @@
"dark": "#1D4ED8"
},
"favicon": "/favicon.svg",
"logo": {
"dark": "/images/logo-dark.svg",
"light": "/images/logo-light.svg"
},
"topbarLinks": [
{
"name": "GitHub",
@@ -69,6 +73,7 @@
"pages": [
"deploy/overview",
"deploy/local-development",
"deploy/tailscale-private-access",
"deploy/docker",
"deploy/deployment-modes",
"deploy/database",

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#2563EB"/>
<path d="M10 8h6a6 6 0 0 1 0 12h-2v4h-4V8zm4 8h2a2 2 0 0 0 0-4h-2v4z" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32" fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="-4" y="-4" width="32" height="32" rx="6" fill="#2563EB"/>
<path stroke="#ffffff" stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg>

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
- **Capabilities** — short description of what this agent does
Common adapter choices:
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
- `openclaw` / `http` for webhook-based external agents
- `process` for generic local command execution
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
Paperclip validates the selected model against live `opencode models` output.
## Agent Hiring via Governance
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.

View File

@@ -2,6 +2,97 @@
How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration.
## Automated Join Smoke Test (Recommended First)
Paperclip includes an end-to-end join smoke harness:
```bash
pnpm smoke:openclaw-join
```
The harness automates:
- invite creation (`allowedJoinTypes=agent`)
- OpenClaw agent join request (`adapterType=openclaw`)
- board approval
- one-time API key claim (including invalid/replay claim checks)
- wakeup callback delivery to a dockerized OpenClaw-style webhook receiver
By default, this uses a preconfigured Docker receiver image (`docker/openclaw-smoke`) so the run is deterministic and requires no manual OpenClaw config edits.
Permissions note:
- The harness performs board-governed actions (invite creation, join approval, wakeup of the new agent).
- In authenticated mode, provide board/operator auth or the run exits early with an explicit permissions error.
## One-Command OpenClaw Gateway UI (Manual Docker Flow)
To spin up OpenClaw in Docker and print a host-browser dashboard URL in one command:
```bash
pnpm smoke:openclaw-docker-ui
```
Default behavior is zero-flag: you can run the command as-is with no pairing-related env vars.
What this command does:
- clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker`
- builds `openclaw:local` (unless `OPENCLAW_BUILD=0`)
- writes isolated smoke config under `~/.openclaw-paperclip-smoke/openclaw.json` and Docker `.env`
- pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback)
- starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override)
- probes and prints a Paperclip host URL that is reachable from inside OpenClaw Docker
- waits for health and prints:
- `http://127.0.0.1:18789/#token=...`
- disables Control UI device pairing by default for local smoke ergonomics
Environment knobs:
- `OPENAI_API_KEY` (required; loaded from env or `~/.secrets`)
- `OPENCLAW_DOCKER_DIR` (default `/tmp/openclaw-docker`)
- `OPENCLAW_GATEWAY_PORT` (default `18789`)
- `OPENCLAW_GATEWAY_TOKEN` (default random)
- `OPENCLAW_BUILD=0` to skip rebuild
- `OPENCLAW_OPEN_BROWSER=1` to auto-open the URL on macOS
- `OPENCLAW_DISABLE_DEVICE_AUTH=1` (default) disables Control UI device pairing for local smoke
- `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands)
- `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`)
- `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`)
- `OPENCLAW_CONFIG_DIR` (default `~/.openclaw-paperclip-smoke`)
- `OPENCLAW_RESET_STATE=1` (default) resets smoke agent state on each run to avoid stale auth/session drift
- `PAPERCLIP_HOST_PORT` (default `3100`)
- `PAPERCLIP_HOST_FROM_CONTAINER` (default `host.docker.internal`)
### Authenticated mode
If your Paperclip deployment is `authenticated`, provide auth context:
```bash
PAPERCLIP_AUTH_HEADER="Bearer <token>" pnpm smoke:openclaw-join
# or
PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
```
### Network topology tips
- Local same-host smoke: default callback uses `http://127.0.0.1:<port>/webhook`.
- Inside OpenClaw Docker, `127.0.0.1` points to the container itself, not your host Paperclip server.
- For invite/onboarding URLs consumed by OpenClaw in Docker, use the script-printed Paperclip URL (typically `http://host.docker.internal:3100`).
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
```bash
pnpm paperclipai allowed-hostname host.docker.internal
```
Then restart Paperclip and rerun the smoke script.
- Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain).
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
```bash
pnpm paperclipai allowed-hostname <host>
```
## Prerequisites
- **Docker Desktop v29+** (with Docker Sandbox support)

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="32" viewBox="0 0 140 32" fill="none">
<g stroke-linecap="round" stroke-linejoin="round">
<path stroke="#e4e4e7" stroke-width="2" d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</g>
<text x="32" y="22" font-family="system-ui, -apple-system, sans-serif" font-size="18" font-weight="600" fill="#e4e4e7">Paperclip</text>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="32" viewBox="0 0 140 32" fill="none">
<g stroke-linecap="round" stroke-linejoin="round">
<path stroke="#18181b" stroke-width="2" d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</g>
<text x="32" y="22" font-family="system-ui, -apple-system, sans-serif" font-size="18" font-weight="600" fill="#18181b">Paperclip</text>
</svg>

After

Width:  |  Height:  |  Size: 474 B

524
docs/specs/cliphub-plan.md Normal file
View File

@@ -0,0 +1,524 @@
# ClipHub: Marketplace for Paperclip Team Configurations
> The "app store" for whole-company AI teams — pre-built Paperclip configurations, agent blueprints, skills, and governance templates that ship real work from day one.
## 1. Vision & Positioning
**ClipHub** sells **entire team configurations** — org charts, agent roles, inter-agent workflows, governance rules, and project templates — for Paperclip-managed companies.
| Dimension | ClipHub |
|---|---|
| Unit of sale | Team blueprint (multi-agent org) |
| Buyer | Founder / team lead spinning up an AI company |
| Install target | Paperclip company (agents, projects, governance) |
| Value prop | "Skip org design — get a shipping team in minutes" |
| Price range | $0$499 per blueprint (+ individual add-ons) |
---
## 2. Product Taxonomy
### 2.1 Team Blueprints (primary product)
A complete Paperclip company configuration:
- **Org chart**: Agents with roles, titles, reporting chains, capabilities
- **Agent configs**: Adapter type, model, prompt templates, instructions paths
- **Governance rules**: Approval flows, budget limits, escalation chains
- **Project templates**: Pre-configured projects with workspace settings
- **Skills & instructions**: AGENTS.md / skill files bundled per agent
**Examples:**
- "SaaS Startup Team" — CEO, CTO, Engineer, CMO, Designer ($199)
- "Content Agency" — Editor-in-Chief, 3 Writers, SEO Analyst, Social Manager ($149)
- "Dev Shop" — CTO, 2 Engineers, QA, DevOps ($99)
- "Solo Founder + Crew" — CEO agent + 3 ICs across eng/marketing/ops ($79)
### 2.2 Agent Blueprints (individual agents within a team context)
Single-agent configurations designed to plug into a Paperclip org:
- Role definition, prompt template, adapter config
- Reporting chain expectations (who they report to)
- Skill bundles included
- Governance defaults (budget, permissions)
**Examples:**
- "Staff Engineer" — ships production code, manages PRs ($29)
- "Growth Marketer" — content pipeline, SEO, social ($39)
- "DevOps Agent" — CI/CD, deployment, monitoring ($29)
### 2.3 Skills (modular capabilities)
Portable skill files that any Paperclip agent can use:
- Markdown skill files with instructions
- Tool configurations and shell scripts
- Compatible with Paperclip's skill loading system
**Examples:**
- "Git PR Workflow" — standardized PR creation and review (Free)
- "Deployment Pipeline" — Cloudflare/Vercel deploy skill ($9)
- "Customer Support Triage" — ticket classification and routing ($19)
### 2.4 Governance Templates
Pre-built approval flows and policies:
- Budget thresholds and approval chains
- Cross-team delegation rules
- Escalation procedures
- Billing code structures
**Examples:**
- "Startup Governance" — lightweight, CEO approves > $50 (Free)
- "Enterprise Governance" — multi-tier approval, audit trail ($49)
---
## 3. Data Schemas
### 3.1 Listing
```typescript
interface Listing {
id: string;
slug: string; // URL-friendly identifier
type: 'team_blueprint' | 'agent_blueprint' | 'skill' | 'governance_template';
title: string;
tagline: string; // Short pitch (≤120 chars)
description: string; // Markdown, full details
// Pricing
price: number; // Cents (0 = free)
currency: 'usd';
// Creator
creatorId: string;
creatorName: string;
creatorAvatar: string | null;
// Categorization
categories: string[]; // e.g. ['saas', 'engineering', 'marketing']
tags: string[]; // e.g. ['claude', 'startup', '5-agent']
agentCount: number | null; // For team blueprints
// Content
previewImages: string[]; // Screenshots / org chart visuals
readmeMarkdown: string; // Full README shown on detail page
includedFiles: string[]; // List of files in the bundle
// Compatibility
compatibleAdapters: string[]; // ['claude_local', 'codex_local', ...]
requiredModels: string[]; // ['claude-opus-4-6', 'claude-sonnet-4-6']
paperclipVersionMin: string; // Minimum Paperclip version
// Social proof
installCount: number;
rating: number | null; // 1.05.0
reviewCount: number;
// Metadata
version: string; // Semver
publishedAt: string;
updatedAt: string;
status: 'draft' | 'published' | 'archived';
}
```
### 3.2 Team Blueprint Bundle
```typescript
interface TeamBlueprint {
listingId: string;
// Org structure
agents: AgentBlueprint[];
reportingChain: { agentSlug: string; reportsTo: string | null }[];
// Governance
governance: {
approvalRules: ApprovalRule[];
budgetDefaults: { role: string; monthlyCents: number }[];
escalationChain: string[]; // Agent slugs in escalation order
};
// Projects
projects: ProjectTemplate[];
// Company-level config
companyDefaults: {
name: string;
defaultModel: string;
defaultAdapter: string;
};
}
interface AgentBlueprint {
slug: string; // e.g. 'cto', 'engineer-1'
name: string;
role: string;
title: string;
icon: string;
capabilities: string;
promptTemplate: string;
adapterType: string;
adapterConfig: Record<string, any>;
instructionsPath: string | null; // Path to AGENTS.md or similar
skills: SkillBundle[];
budgetMonthlyCents: number;
permissions: {
canCreateAgents: boolean;
canApproveHires: boolean;
};
}
interface ProjectTemplate {
name: string;
description: string;
workspace: {
cwd: string | null;
repoUrl: string | null;
} | null;
}
interface ApprovalRule {
trigger: string; // e.g. 'hire_agent', 'budget_exceed'
threshold: number | null;
approverRole: string;
}
```
### 3.3 Creator / Seller
```typescript
interface Creator {
id: string;
userId: string; // Auth provider ID
displayName: string;
bio: string;
avatarUrl: string | null;
website: string | null;
listings: string[]; // Listing IDs
totalInstalls: number;
totalRevenue: number; // Cents earned
joinedAt: string;
verified: boolean;
payoutMethod: 'stripe_connect';
stripeAccountId: string | null;
}
```
### 3.4 Purchase / Install
```typescript
interface Purchase {
id: string;
listingId: string;
buyerUserId: string;
buyerCompanyId: string | null; // Target Paperclip company
pricePaidCents: number;
paymentIntentId: string | null; // Stripe
installedAt: string | null; // When deployed to company
status: 'pending' | 'completed' | 'refunded';
createdAt: string;
}
```
### 3.5 Review
```typescript
interface Review {
id: string;
listingId: string;
authorUserId: string;
authorDisplayName: string;
rating: number; // 15
title: string;
body: string; // Markdown
verifiedPurchase: boolean;
createdAt: string;
updatedAt: string;
}
```
---
## 4. Pages & Routes
### 4.1 Public Pages
| Route | Page | Description |
|---|---|---|
| `/` | Homepage | Hero, featured blueprints, popular skills, how it works |
| `/browse` | Marketplace browse | Filterable grid of all listings |
| `/browse?type=team_blueprint` | Team blueprints | Filtered to team configs |
| `/browse?type=agent_blueprint` | Agent blueprints | Single-agent configs |
| `/browse?type=skill` | Skills | Skill listings |
| `/browse?type=governance_template` | Governance | Policy templates |
| `/listings/:slug` | Listing detail | Full product page |
| `/creators/:slug` | Creator profile | Bio, all listings, stats |
| `/about` | About ClipHub | Mission, how it works |
| `/pricing` | Pricing & fees | Creator revenue share, buyer info |
### 4.2 Authenticated Pages
| Route | Page | Description |
|---|---|---|
| `/dashboard` | Buyer dashboard | Purchased items, installed blueprints |
| `/dashboard/purchases` | Purchase history | All transactions |
| `/dashboard/installs` | Installations | Deployed blueprints with status |
| `/creator` | Creator dashboard | Listing management, analytics |
| `/creator/listings/new` | Create listing | Multi-step listing wizard |
| `/creator/listings/:id/edit` | Edit listing | Modify existing listing |
| `/creator/analytics` | Analytics | Revenue, installs, views |
| `/creator/payouts` | Payouts | Stripe Connect payout history |
### 4.3 API Routes
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/listings` | Browse listings (filters: type, category, price range, sort) |
| `GET` | `/api/listings/:slug` | Get listing detail |
| `POST` | `/api/listings` | Create listing (creator auth) |
| `PATCH` | `/api/listings/:id` | Update listing |
| `DELETE` | `/api/listings/:id` | Archive listing |
| `POST` | `/api/listings/:id/purchase` | Purchase listing (Stripe checkout) |
| `POST` | `/api/listings/:id/install` | Install to Paperclip company |
| `GET` | `/api/listings/:id/reviews` | Get reviews |
| `POST` | `/api/listings/:id/reviews` | Submit review |
| `GET` | `/api/creators/:slug` | Creator profile |
| `GET` | `/api/creators/me` | Current creator profile |
| `POST` | `/api/creators` | Register as creator |
| `GET` | `/api/purchases` | Buyer's purchase history |
| `GET` | `/api/analytics` | Creator analytics |
---
## 5. User Flows
### 5.1 Buyer: Browse → Purchase → Install
```
Homepage → Browse marketplace → Filter by type/category
→ Click listing → Read details, reviews, preview org chart
→ Click "Buy" → Stripe checkout (or free install)
→ Post-purchase: "Install to Company" button
→ Select target Paperclip company (or create new)
→ ClipHub API calls Paperclip API to:
1. Create agents with configs from blueprint
2. Set up reporting chains
3. Create projects with workspace configs
4. Apply governance rules
5. Deploy skill files to agent instruction paths
→ Redirect to Paperclip dashboard with new team running
```
### 5.2 Creator: Build → Publish → Earn
```
Sign up as creator → Connect Stripe
→ "New Listing" wizard:
Step 1: Type (team/agent/skill/governance)
Step 2: Basic info (title, tagline, description, categories)
Step 3: Upload bundle (JSON config + skill files + README)
Step 4: Preview & org chart visualization
Step 5: Pricing ($0$499)
Step 6: Publish
→ Live on marketplace immediately
→ Track installs, revenue, reviews on creator dashboard
```
### 5.3 Creator: Export from Paperclip → Publish
```
Running Paperclip company → "Export as Blueprint" (CLI or UI)
→ Paperclip exports:
- Agent configs (sanitized — no secrets)
- Org chart / reporting chains
- Governance rules
- Project templates
- Skill files
→ Upload to ClipHub as new listing
→ Edit details, set price, publish
```
---
## 6. UI Design Direction
### 6.1 Visual Language
- **Color palette**: Dark ink primary, warm sand backgrounds, accent color for CTAs (Paperclip brand blue/purple)
- **Typography**: Clean sans-serif, strong hierarchy, monospace for technical details
- **Cards**: Rounded corners, subtle shadows, clear pricing badges
- **Org chart visuals**: Interactive tree/graph showing agent relationships in team blueprints
### 6.2 Key Design Elements
| Element | ClipHub |
|---|---|
| Product card | Org chart mini-preview + agent count badge |
| Detail page | Interactive org chart + per-agent breakdown |
| Install flow | One-click deploy to Paperclip company |
| Social proof | "X companies running this blueprint" |
| Preview | Live demo sandbox (stretch goal) |
### 6.3 Listing Card Design
```
┌─────────────────────────────────────┐
│ [Org Chart Mini-Preview] │
│ ┌─CEO─┐ │
│ ├─CTO─┤ │
│ └─ENG──┘ │
│ │
│ SaaS Startup Team │
│ "Ship your MVP with a 5-agent │
│ engineering + marketing team" │
│ │
│ 👥 5 agents ⬇ 234 installs │
│ ★ 4.7 (12 reviews) │
│ │
│ By @masinov $199 [Buy] │
└─────────────────────────────────────┘
```
### 6.4 Detail Page Sections
1. **Hero**: Title, tagline, price, install button, creator info
2. **Org Chart**: Interactive visualization of agent hierarchy
3. **Agent Breakdown**: Expandable cards for each agent — role, capabilities, model, skills
4. **Governance**: Approval flows, budget structure, escalation chain
5. **Included Projects**: Project templates with workspace configs
6. **README**: Full markdown documentation
7. **Reviews**: Star ratings + written reviews
8. **Related Blueprints**: Cross-sell similar team configs
9. **Creator Profile**: Mini bio, other listings
---
## 7. Installation Mechanics
### 7.1 Install API Flow
When a buyer clicks "Install to Company":
```
POST /api/listings/:id/install
{
"targetCompanyId": "uuid", // Existing Paperclip company
"overrides": { // Optional customization
"agentModel": "claude-sonnet-4-6", // Override default model
"budgetScale": 0.5, // Scale budgets
"skipProjects": false
}
}
```
The install handler:
1. Validates buyer owns the purchase
2. Validates target company access
3. For each agent in blueprint:
- `POST /api/companies/:id/agents` (if `paperclip-create-agent` supports it, or via approval flow)
- Sets adapter config, prompt template, instructions path
4. Sets reporting chains
5. Creates projects and workspaces
6. Applies governance rules
7. Deploys skill files to configured paths
8. Returns summary of created resources
### 7.2 Conflict Resolution
- **Agent name collision**: Append `-2`, `-3` suffix
- **Project name collision**: Prompt buyer to rename or skip
- **Adapter mismatch**: Warn if blueprint requires adapter not available locally
- **Model availability**: Warn if required model not configured
---
## 8. Revenue Model
| Fee | Amount | Notes |
|---|---|---|
| Creator revenue share | 90% of sale price | Minus Stripe processing (~2.9% + $0.30) |
| Platform fee | 10% of sale price | ClipHub's cut |
| Free listings | $0 | No fees for free listings |
| Stripe Connect | Standard rates | Handled by Stripe |
---
## 9. Technical Architecture
### 9.1 Stack
- **Frontend**: Next.js (React), Tailwind CSS, same UI framework as Paperclip
- **Backend**: Node.js API (or extend Paperclip server)
- **Database**: Postgres (can share Paperclip's DB or separate)
- **Payments**: Stripe Connect (marketplace mode)
- **Storage**: S3/R2 for listing bundles and images
- **Auth**: Shared with Paperclip auth (or OAuth2)
### 9.2 Integration with Paperclip
ClipHub can be:
- **Option A**: A separate app that calls Paperclip's API to install blueprints
- **Option B**: A built-in section of the Paperclip UI (`/marketplace` route)
Option B is simpler for MVP — adds routes to the existing Paperclip UI and API.
### 9.3 Bundle Format
Listing bundles are ZIP/tar archives containing:
```
blueprint/
├── manifest.json # Listing metadata + agent configs
├── README.md # Documentation
├── org-chart.json # Agent hierarchy
├── governance.json # Approval rules, budgets
├── agents/
│ ├── ceo/
│ │ ├── prompt.md # Prompt template
│ │ ├── AGENTS.md # Instructions
│ │ └── skills/ # Skill files
│ ├── cto/
│ │ ├── prompt.md
│ │ ├── AGENTS.md
│ │ └── skills/
│ └── engineer/
│ ├── prompt.md
│ ├── AGENTS.md
│ └── skills/
└── projects/
└── default/
└── workspace.json # Project workspace config
```
---
## 10. MVP Scope
### Phase 1: Foundation
- [ ] Listing schema and CRUD API
- [ ] Browse page with filters (type, category, price)
- [ ] Listing detail page with org chart visualization
- [ ] Creator registration and listing creation wizard
- [ ] Free installs only (no payments yet)
- [ ] Install flow: blueprint → Paperclip company
### Phase 2: Payments & Social
- [ ] Stripe Connect integration
- [ ] Purchase flow
- [ ] Review system
- [ ] Creator analytics dashboard
- [ ] "Export from Paperclip" CLI command
### Phase 3: Growth
- [ ] Search with relevance ranking
- [ ] Featured/trending listings
- [ ] Creator verification program
- [ ] Blueprint versioning and update notifications
- [ ] Live demo sandbox
- [ ] API for programmatic publishing

View File

@@ -5,24 +5,15 @@ summary: Get Paperclip running in minutes
Get Paperclip running locally in under 5 minutes.
## Option 1: Docker Compose (Recommended)
The fastest way to start. No Node.js install needed.
## Quick Start (Recommended)
```sh
docker compose -f docker-compose.quickstart.yml up --build
npx paperclipai onboard --yes
```
Open [http://localhost:3100](http://localhost:3100). That's it.
This walks you through setup, configures your environment, and gets Paperclip running.
The Docker image includes Claude Code CLI and Codex CLI pre-installed for local adapter runs. Pass API keys to enable them:
```sh
ANTHROPIC_API_KEY=sk-... OPENAI_API_KEY=sk-... \
docker compose -f docker-compose.quickstart.yml up --build
```
## Option 2: Local Development
## Local Development
Prerequisites: Node.js 20+ and pnpm 9+.
@@ -33,9 +24,9 @@ pnpm dev
This starts the API server and UI at [http://localhost:3100](http://localhost:3100).
No Docker or external database required — Paperclip uses an embedded PostgreSQL instance by default.
No external database required — Paperclip uses an embedded PostgreSQL instance by default.
## Option 3: One-Command Bootstrap
## One-Command Bootstrap
```sh
pnpm paperclipai run

View File

@@ -3,8 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs dev",
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"build": "pnpm -r build",
@@ -21,7 +22,10 @@
"changeset": "changeset",
"version-packages": "changeset version",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"docs:dev": "cd docs && npx mintlify dev"
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",

View File

@@ -1,5 +1,35 @@
# @paperclipai/adapter-utils
## 0.2.7
### Patch Changes
- Version bump (patch)
## 0.2.6
### Patch Changes
- Version bump (patch)
## 0.2.5
### Patch Changes
- Version bump (patch)
## 0.2.4
### Patch Changes
- Version bump (patch)
## 0.2.3
### Patch Changes
- Version bump (patch)
## 0.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-utils",
"version": "0.2.2",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -30,6 +30,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -13,6 +13,8 @@ export type {
AdapterEnvironmentTestContext,
AdapterSessionCodec,
AdapterModel,
HireApprovedPayload,
HireApprovedHookResult,
ServerAdapterModule,
TranscriptEntry,
StdoutLineParser,

View File

@@ -15,6 +15,14 @@ interface RunningProcess {
graceSec: number;
}
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
event: "close",
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): ChildProcess;
};
export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
@@ -217,7 +225,7 @@ export async function runChildProcess(
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
}) as ChildProcessWithEvents;
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
@@ -244,7 +252,7 @@ export async function runChildProcess(
}, opts.timeoutSec * 1000)
: null;
child.stdout?.on("data", (chunk) => {
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
@@ -252,7 +260,7 @@ export async function runChildProcess(
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
child.stderr?.on("data", (chunk) => {
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
@@ -260,7 +268,7 @@ export async function runChildProcess(
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
child.on("error", (err) => {
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
@@ -272,7 +280,7 @@ export async function runChildProcess(
reject(new Error(msg));
});
child.on("close", (code, signal) => {
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {

View File

@@ -119,6 +119,27 @@ export interface AdapterEnvironmentTestContext {
};
}
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
export interface HireApprovedPayload {
companyId: string;
agentId: string;
agentName: string;
adapterType: string;
/** "join_request" | "approval" */
source: "join_request" | "approval";
sourceId: string;
approvedAt: string;
/** Canonical operator-facing message for cloud adapters to show the user. */
message: string;
}
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
export interface HireApprovedHookResult {
ok: boolean;
error?: string;
detail?: Record<string, unknown>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@@ -128,6 +149,14 @@ export interface ServerAdapterModule {
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;
agentConfigurationDoc?: string;
/**
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
*/
onHireApproved?: (
payload: HireApprovedPayload,
adapterConfig: Record<string, unknown>,
) => Promise<HireApprovedHookResult>;
}
// ---------------------------------------------------------------------------
@@ -135,8 +164,8 @@ export interface ServerAdapterModule {
// ---------------------------------------------------------------------------
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string }
| { kind: "thinking"; ts: string; text: string }
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown }
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }

View File

@@ -1,5 +1,45 @@
# @paperclipai/adapter-claude-local
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.3
## 0.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-claude-local",
"version": "0.2.2",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -32,7 +32,8 @@
"types": "./dist/index.d.ts"
},
"files": [
"dist"
"dist",
"skills"
],
"scripts": {
"build": "tsc",
@@ -44,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -27,10 +27,19 @@ import {
isClaudeUnknownSessionError,
} from "./parse.js";
const PAPERCLIP_SKILLS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../skills",
);
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
/**
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
@@ -41,11 +50,13 @@ async function buildSkillsDir(): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return tmp;
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await fs.symlink(
path.join(PAPERCLIP_SKILLS_DIR, entry.name),
path.join(skillsDir, entry.name),
path.join(target, entry.name),
);
}

View File

@@ -1,5 +1,45 @@
# @paperclipai/adapter-codex-local
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.3
## 0.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-codex-local",
"version": "0.2.2",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -32,7 +32,8 @@
"types": "./dist/index.d.ts"
},
"files": [
"dist"
"dist",
"skills"
],
"scripts": {
"build": "tsc",
@@ -44,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -4,6 +4,7 @@ export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const models = [
{ id: "gpt-5.4", label: "gpt-5.4" },
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
{ id: "gpt-5", label: "gpt-5" },

View File

@@ -19,10 +19,11 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
const PAPERCLIP_SKILLS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../skills",
);
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
@@ -66,19 +67,24 @@ function codexHomeDir(): string {
return path.join(os.homedir(), ".codex");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const sourceExists = await fs
.stat(PAPERCLIP_SKILLS_DIR)
.then((stats) => stats.isDirectory())
.catch(() => false);
if (!sourceExists) return;
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsHome = path.join(codexHomeDir(), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(PAPERCLIP_SKILLS_DIR, entry.name);
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;

View File

@@ -0,0 +1,7 @@
# @paperclipai/adapter-cursor-local
## 0.2.7
### Patch Changes
- Added initial `cursor` adapter package for local Cursor CLI execution

View File

@@ -0,0 +1,51 @@
{
"name": "@paperclipai/adapter-cursor-local",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,317 @@
import pc from "picocolors";
import { normalizeCursorStreamLine } from "../shared/stream.js";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function printUserMessage(messageRaw: unknown): void {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
if (text) console.log(pc.gray(`user: ${text}`));
return;
}
const message = asRecord(messageRaw);
if (!message) return;
const directText = asString(message.text).trim();
if (directText) console.log(pc.gray(`user: ${directText}`));
const content = Array.isArray(message.content) ? message.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type !== "output_text" && type !== "text") continue;
const text = asString(part.text).trim();
if (text) console.log(pc.gray(`user: ${text}`));
}
}
function printAssistantMessage(messageRaw: unknown): void {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
const message = asRecord(messageRaw);
if (!message) return;
const directText = asString(message.text).trim();
if (directText) console.log(pc.green(`assistant: ${directText}`));
const content = Array.isArray(message.content) ? message.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type === "output_text" || type === "text") {
const text = asString(part.text).trim();
if (text) console.log(pc.green(`assistant: ${text}`));
continue;
}
if (type === "thinking") {
const text = asString(part.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
continue;
}
if (type === "tool_call") {
const name = asString(part.name, asString(part.tool, "tool"));
console.log(pc.yellow(`tool_call: ${name}`));
const input = part.input ?? part.arguments ?? part.args;
if (input !== undefined) {
try {
console.log(pc.gray(JSON.stringify(input, null, 2)));
} catch {
console.log(pc.gray(String(input)));
}
}
continue;
}
if (type === "tool_result") {
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
const contentText =
asString(part.output) ||
asString(part.text) ||
asString(part.result) ||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
}
}
}
function printToolCallEventTopLevel(parsed: Record<string, unknown>): void {
const subtype = asString(parsed.subtype).trim().toLowerCase();
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "")));
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
if (!toolCall) {
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
return;
}
const [toolName] = Object.keys(toolCall);
if (!toolName) {
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
return;
}
const payload = asRecord(toolCall[toolName]) ?? {};
const args = payload.args ?? asRecord(payload.function)?.arguments;
const result =
payload.result ??
payload.output ??
payload.error ??
asRecord(payload.function)?.result ??
asRecord(payload.function)?.output;
const isError =
parsed.is_error === true ||
payload.is_error === true ||
subtype === "failed" ||
subtype === "error" ||
subtype === "cancelled" ||
payload.error !== undefined;
if (subtype === "started" || subtype === "start") {
console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`));
if (args !== undefined) {
console.log(pc.gray(stringifyUnknown(args)));
}
return;
}
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`;
console.log((isError ? pc.red : pc.cyan)(header));
if (result !== undefined) {
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result)));
}
return;
}
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
}
function printLegacyToolEvent(part: Record<string, unknown>): void {
const tool = asString(part.tool, "tool");
const callId = asString(part.callID, asString(part.id, ""));
const state = asRecord(part.state);
const status = asString(state?.status);
const input = state?.input;
const output = asString(state?.output).replace(/\s+$/, "");
const metadata = asRecord(state?.metadata);
const exit = asNumber(metadata?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exit) && exit !== 0);
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
if (input !== undefined) {
try {
console.log(pc.gray(JSON.stringify(input, null, 2)));
} catch {
console.log(pc.gray(String(input)));
}
}
if (status || output) {
const summary = [
"tool_result",
status ? `status=${status}` : "",
Number.isFinite(exit) ? `exit=${exit}` : "",
]
.filter(Boolean)
.join(" ");
console.log((isError ? pc.red : pc.cyan)(summary));
if (output) {
console.log((isError ? pc.red : pc.gray)(output));
}
}
}
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
const line = normalizeCursorStreamLine(raw).line;
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {
const sessionId =
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID);
const model = asString(parsed.model);
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
.filter(Boolean)
.join(", ");
console.log(pc.blue(`Cursor init${details ? ` (${details})` : ""}`));
return;
}
console.log(pc.blue(`system: ${subtype || "event"}`));
return;
}
if (type === "assistant") {
printAssistantMessage(parsed.message);
return;
}
if (type === "user") {
printUserMessage(parsed.message);
return;
}
if (type === "thinking") {
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "tool_call") {
printToolCallEventTopLevel(parsed);
return;
}
if (type === "result") {
const usage = asRecord(parsed.usage);
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
const output = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
const cached = asNumber(
usage?.cached_input_tokens,
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
);
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
const subtype = asString(parsed.subtype, "result");
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
console.log(pc.blue(`result: subtype=${subtype}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
const resultText = asString(parsed.result).trim();
if (resultText) console.log((isError ? pc.red : pc.green)(`assistant: ${resultText}`));
const errors = Array.isArray(parsed.errors) ? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean) : [];
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
return;
}
if (type === "error") {
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
console.log(pc.red(`error: ${message}`));
return;
}
// Compatibility with older stream-json event shapes.
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
return;
}
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text);
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
if (part) {
printLegacyToolEvent(part);
} else {
console.log(pc.yellow("tool_use"));
}
return;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason, "step_finish");
const input = asNumber(tokens?.input);
const output = asNumber(tokens?.output);
const cached = asNumber(cache?.read);
const cost = asNumber(part?.cost);
console.log(pc.blue(`step finished: reason=${reason}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
return;
}
console.log(line);
}

View File

@@ -0,0 +1 @@
export { printCursorStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,83 @@
export const type = "cursor";
export const label = "Cursor CLI (local)";
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
const CURSOR_FALLBACK_MODEL_IDS = [
"auto",
"composer-1.5",
"composer-1",
"gpt-5.3-codex-low",
"gpt-5.3-codex-low-fast",
"gpt-5.3-codex",
"gpt-5.3-codex-fast",
"gpt-5.3-codex-high",
"gpt-5.3-codex-high-fast",
"gpt-5.3-codex-xhigh",
"gpt-5.3-codex-xhigh-fast",
"gpt-5.3-codex-spark-preview",
"gpt-5.2",
"gpt-5.2-codex-low",
"gpt-5.2-codex-low-fast",
"gpt-5.2-codex",
"gpt-5.2-codex-fast",
"gpt-5.2-codex-high",
"gpt-5.2-codex-high-fast",
"gpt-5.2-codex-xhigh",
"gpt-5.2-codex-xhigh-fast",
"gpt-5.1-codex-max",
"gpt-5.1-codex-max-high",
"gpt-5.2-high",
"gpt-5.1-high",
"gpt-5.1-codex-mini",
"opus-4.6-thinking",
"opus-4.6",
"opus-4.5",
"opus-4.5-thinking",
"sonnet-4.6",
"sonnet-4.6-thinking",
"sonnet-4.5",
"sonnet-4.5-thinking",
"gemini-3.1-pro",
"gemini-3-pro",
"gemini-3-flash",
"grok",
"kimi-k2.5",
];
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
export const agentConfigurationDoc = `# cursor agent configuration
Adapter: cursor
Use when:
- You want Paperclip to run Cursor Agent CLI locally as the agent runtime
- You want Cursor chat session resume across heartbeats via --resume
- You want structured stream output in run logs via --output-format stream-json
Don't use when:
- You need webhook-style external invocation (use openclaw_gateway or http)
- You only need one-shot shell commands (use process)
- Cursor Agent CLI is not installed on the machine
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Cursor model id (for example auto or gpt-5.3-codex)
- mode (string, optional): Cursor execution mode passed as --mode (plan|ask). Leave unset for normal autonomous runs.
- command (string, optional): defaults to "agent"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Runs are executed with: agent -p --output-format stream-json ...
- Prompts are piped to Cursor via stdin.
- Sessions are resumed with --resume when stored session cwd matches current cwd.
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
`;

View File

@@ -0,0 +1,485 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
? "api"
: "subscription";
}
function resolveProviderFromModel(model: string): string | null {
const trimmed = model.trim().toLowerCase();
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
if (slash > 0) return trimmed.slice(0, slash);
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
return null;
}
function normalizeMode(rawMode: string): "plan" | "ask" | null {
const mode = rawMode.trim().toLowerCase();
if (mode === "plan" || mode === "ask") return mode;
return null;
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (paperclipKeys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
function cursorSkillsHome(): string {
return path.join(os.homedir(), ".cursor", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
type EnsureCursorSkillsInjectedOptions = {
skillsDir?: string | null;
skillsHome?: string;
linkSkill?: (source: string, target: string) => Promise<void>;
};
export async function ensureCursorSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCursorSkillsInjectedOptions = {},
) {
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsHome = options.skillsHome ?? cursorSkillsHome();
try {
await fs.mkdir(skillsHome, { recursive: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to prepare Cursor skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await linkSkill(source, target);
await onLog(
"stderr",
`[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Cursor skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const command = asString(config.command, "agent");
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
const mode = normalizeMode(asString(config.mode, ""));
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureCursorSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
if (wakeReason) {
env.PAPERCLIP_WAKE_REASON = wakeReason;
}
if (wakeCommentId) {
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
}
if (approvalId) {
env.PAPERCLIP_APPROVAL_ID = approvalId;
}
if (approvalStatus) {
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
}
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (effectiveWorkspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveCursorBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, 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 autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
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(
"stderr",
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
const notes: string[] = [];
if (autoTrustEnabled) {
notes.push("Auto-added --yolo to bypass interactive prompts.");
}
notes.push("Prompt is piped to Cursor via stdin.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
);
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const buildArgs = (resumeSessionId: string | null) => {
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model) args.push("--model", model);
if (mode) args.push("--mode", mode);
if (autoTrustEnabled) args.push("--yolo");
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "cursor",
command,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
prompt,
context,
});
}
let stdoutLineBuffer = "";
const emitNormalizedStdoutLine = async (rawLine: string) => {
const normalized = normalizeCursorStreamLine(rawLine);
if (!normalized.line) return;
await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`);
};
const flushStdoutChunk = async (chunk: string, finalize = false) => {
const combined = `${stdoutLineBuffer}${chunk}`;
const lines = combined.split(/\r?\n/);
stdoutLineBuffer = lines.pop() ?? "";
for (const line of lines) {
await emitNormalizedStdoutLine(line);
}
if (finalize) {
const trailing = stdoutLineBuffer.trim();
stdoutLineBuffer = "";
if (trailing) {
await emitNormalizedStdoutLine(trailing);
}
}
};
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
timeoutSec,
graceSec,
stdin: prompt,
onLog: async (stream, chunk) => {
if (stream !== "stdout") {
await onLog(stream, chunk);
return;
}
await flushStdoutChunk(chunk);
},
});
await flushStdoutChunk("", true);
return {
proc,
parsed: parseCursorJsonl(proc.stdout),
};
};
const providerFromModel = resolveProviderFromModel(model);
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
parsed: ReturnType<typeof parseCursorJsonl>;
},
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 ?? 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 fallbackErrorMessage =
parsedError ||
stderrLine ||
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage:
(attempt.proc.exitCode ?? 0) === 0
? null
: fallbackErrorMessage,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: providerFromModel,
model,
billingType,
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stderr",
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
}
return toResult(initial);
}

View File

@@ -0,0 +1,64 @@
export { execute, ensureCursorSkillsInjected } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};

View File

@@ -0,0 +1,162 @@
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
import { normalizeCursorStreamLine } from "../shared/stream.js";
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message =
asString(rec.message, "") ||
asString(rec.error, "") ||
asString(rec.code, "") ||
asString(rec.detail, "");
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function collectAssistantText(message: unknown): string[] {
if (typeof message === "string") {
const trimmed = message.trim();
return trimmed ? [trimmed] : [];
}
const rec = parseObject(message);
const direct = asString(rec.text, "").trim();
const lines: string[] = direct ? [direct] : [];
const content = Array.isArray(rec.content) ? rec.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
const type = asString(part.type, "").trim();
if (type === "output_text" || type === "text") {
const text = asString(part.text, "").trim();
if (text) lines.push(text);
}
}
return lines;
}
function readSessionId(event: Record<string, unknown>): string | null {
return (
asString(event.session_id, "").trim() ||
asString(event.sessionId, "").trim() ||
asString(event.sessionID, "").trim() ||
null
);
}
export function parseCursorJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let totalCostUsd = 0;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
};
for (const rawLine of stdout.split(/\r?\n/)) {
const line = normalizeCursorStreamLine(rawLine).line;
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const foundSession = readSessionId(event);
if (foundSession) sessionId = foundSession;
const type = asString(event.type, "").trim();
if (type === "assistant") {
messages.push(...collectAssistantText(event.message));
continue;
}
if (type === "result") {
const usageObj = parseObject(event.usage);
usage.inputTokens += asNumber(
usageObj.input_tokens,
asNumber(usageObj.inputTokens, 0),
);
usage.cachedInputTokens += asNumber(
usageObj.cached_input_tokens,
asNumber(usageObj.cachedInputTokens, asNumber(usageObj.cache_read_input_tokens, 0)),
);
usage.outputTokens += asNumber(
usageObj.output_tokens,
asNumber(usageObj.outputTokens, 0),
);
totalCostUsd += asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, 0)));
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
const resultText = asString(event.result, "").trim();
if (resultText && messages.length === 0) {
messages.push(resultText);
}
if (isError) {
const resultError = asErrorText(event.error ?? event.message ?? event.result).trim();
if (resultError) errorMessage = resultError;
}
continue;
}
if (type === "error") {
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
if (message) errorMessage = message;
continue;
}
if (type === "system") {
const subtype = asString(event.subtype, "").trim().toLowerCase();
if (subtype === "error") {
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
if (message) errorMessage = message;
}
continue;
}
// Compatibility with older stream-json shapes.
if (type === "text") {
const part = parseObject(event.part);
const text = asString(part.text, "").trim();
if (text) messages.push(text);
continue;
}
if (type === "step_finish") {
const part = parseObject(event.part);
const tokens = parseObject(part.tokens);
const cache = parseObject(tokens.cache);
usage.inputTokens += asNumber(tokens.input, 0);
usage.cachedInputTokens += asNumber(cache.read, 0);
usage.outputTokens += asNumber(tokens.output, 0);
totalCostUsd += asNumber(part.cost, 0);
continue;
}
}
return {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
errorMessage,
};
}
export function isCursorUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+(session|chat)|session\s+.*\s+not\s+found|chat\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|could\s+not\s+resume/i.test(
haystack,
);
}

View File

@@ -0,0 +1,210 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}` : clean;
}
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;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "agent");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "cursor_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "cursor_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "cursor_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "cursor_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const configCursorApiKey = env.CURSOR_API_KEY;
const hostCursorApiKey = process.env.CURSOR_API_KEY;
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "cursor_api_key_present",
level: "info",
message: "CURSOR_API_KEY is set for Cursor authentication.",
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 canRunProbe =
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
if (canRunProbe) {
if (!commandLooksLike(command, "agent")) {
checks.push({
code: "cursor_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not `agent`.",
detail: command,
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
});
} else {
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
const args = ["-p", "--mode", "ask", "--output-format", "json", "--workspace", cwd];
if (model) args.push("--model", model);
if (autoTrustEnabled) args.push("--yolo");
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
const probe = await runChildProcess(
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env,
timeoutSec: 45,
graceSec: 5,
onLog: async () => {},
},
);
const parsed = parseCursorJsonl(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: "cursor_hello_probe_timed_out",
level: "warn",
message: "Cursor hello probe timed out.",
hint: "Retry the probe. If this persists, verify `agent -p --mode ask --output-format json \"Respond with hello.\"` manually.",
});
} else if ((probe.exitCode ?? 1) === 0) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "cursor_hello_probe_passed" : "cursor_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Cursor hello probe succeeded."
: "Cursor probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.",
}),
});
} else if (CURSOR_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "cursor_hello_probe_auth_required",
level: "warn",
message: "Cursor CLI is installed, but authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Run `agent login` or configure CURSOR_API_KEY in adapter env/shell, then retry the probe.",
});
} else {
checks.push({
code: "cursor_hello_probe_failed",
level: "error",
message: "Cursor hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `agent -p --mode ask --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
});
}
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,16 @@
export function normalizeCursorStreamLine(rawLine: string): {
stream: "stdout" | "stderr" | null;
line: string;
} {
const trimmed = rawLine.trim();
if (!trimmed) return { stream: null, line: "" };
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*([\[{].*)$/i);
if (!prefixed) {
return { stream: null, line: trimmed };
}
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
const line = (prefixed[2] ?? "").trim();
return { stream, line };
}

View File

@@ -0,0 +1,9 @@
export function hasCursorTrustBypassArg(args: readonly string[]): boolean {
return args.some(
(arg) =>
arg === "--trust" ||
arg === "--yolo" ||
arg === "-f" ||
arg.startsWith("--trust="),
);
}

View File

@@ -0,0 +1,81 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function normalizeMode(value: string): "plan" | "ask" | null {
const mode = value.trim().toLowerCase();
if (mode === "plan" || mode === "ask") return mode;
return null;
}
export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parseCursorStdoutLine } from "./parse-stdout.js";
export { buildCursorLocalConfig } from "./build-config.js";

View File

@@ -0,0 +1,400 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { normalizeCursorStreamLine } from "../shared/stream.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/** Max chars of stdout/stderr to show in run log for shell tool results. */
const SHELL_OUTPUT_TRUNCATE = 2000;
/**
* Format shell tool result for run log: exit code + stdout/stderr (truncated).
* If the result is not a shell-shaped object, returns full stringify.
*/
function formatShellToolResultForLog(result: unknown): string {
const obj = asRecord(result);
if (!obj) return stringifyUnknown(result);
const success = asRecord(obj.success);
if (!success) return stringifyUnknown(result);
const exitCode = asNumber(success.exitCode, NaN);
const stdout = asString(success.stdout).trim();
const stderr = asString(success.stderr).trim();
const hasShellShape = Number.isFinite(exitCode) || stdout.length > 0 || stderr.length > 0;
if (!hasShellShape) return stringifyUnknown(result);
const lines: string[] = [];
if (Number.isFinite(exitCode)) lines.push(`exit ${exitCode}`);
if (stdout) {
const out = stdout.length > SHELL_OUTPUT_TRUNCATE ? stdout.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stdout;
lines.push("<stdout>");
lines.push(out);
}
if (stderr) {
const err = stderr.length > SHELL_OUTPUT_TRUNCATE ? stderr.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stderr;
lines.push("<stderr>");
lines.push(err);
}
return lines.join("\n");
}
/** Return compact input for run log when tool is shell/shellToolCall (command only). */
function compactShellToolInput(rawInput: unknown, payload?: Record<string, unknown>): unknown {
const cmd = asString(payload?.command ?? asRecord(rawInput)?.command);
if (cmd) return { command: cmd };
return rawInput;
}
function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
return text ? [{ kind: "user", ts, text }] : [];
}
const message = asRecord(messageRaw);
if (!message) return [];
const entries: TranscriptEntry[] = [];
const directText = asString(message.text).trim();
if (directText) entries.push({ kind: "user", ts, text: directText });
const content = Array.isArray(message.content) ? message.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type !== "output_text" && type !== "text") continue;
const text = asString(part.text).trim();
if (text) entries.push({ kind: "user", ts, text });
}
return entries;
}
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
return text ? [{ kind: "assistant", ts, text }] : [];
}
const message = asRecord(messageRaw);
if (!message) return [];
const entries: TranscriptEntry[] = [];
const directText = asString(message.text).trim();
if (directText) {
entries.push({ kind: "assistant", ts, text: directText });
}
const content = Array.isArray(message.content) ? message.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type === "output_text" || type === "text") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "assistant", ts, text });
continue;
}
if (type === "thinking") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "thinking", ts, text });
continue;
}
if (type === "tool_call") {
const name = asString(part.name, asString(part.tool, "tool"));
const rawInput = part.input ?? part.arguments ?? part.args ?? {};
const input =
name === "shellToolCall" || name === "shell"
? compactShellToolInput(rawInput, asRecord(rawInput) ?? undefined)
: rawInput;
entries.push({
kind: "tool_call",
ts,
name,
input,
});
continue;
}
if (type === "tool_result") {
const toolUseId =
asString(part.tool_use_id) ||
asString(part.toolUseId) ||
asString(part.call_id) ||
asString(part.id) ||
"tool_result";
const rawOutput = part.output ?? part.result ?? part.text;
const contentText =
typeof rawOutput === "object" && rawOutput !== null
? formatShellToolResultForLog(rawOutput)
: asString(rawOutput) || stringifyUnknown(rawOutput);
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: contentText,
isError,
});
}
}
return entries;
}
function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): TranscriptEntry[] {
const subtype = asString(event.subtype).trim().toLowerCase();
const callId =
asString(event.call_id) ||
asString(event.callId) ||
asString(event.id) ||
"tool_call";
const toolCall = asRecord(event.tool_call ?? event.toolCall);
if (!toolCall) {
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
}
const [toolName] = Object.keys(toolCall);
if (!toolName) {
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
}
const payload = asRecord(toolCall[toolName]) ?? {};
const rawInput = payload.args ?? asRecord(payload.function)?.arguments ?? payload;
const isShellTool = toolName === "shellToolCall" || toolName === "shell";
const input = isShellTool ? compactShellToolInput(rawInput, payload) : rawInput;
if (subtype === "started" || subtype === "start") {
return [{
kind: "tool_call",
ts,
name: toolName,
input,
}];
}
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
const result =
payload.result ??
payload.output ??
payload.error ??
asRecord(payload.function)?.result ??
asRecord(payload.function)?.output;
const isError =
event.is_error === true ||
payload.is_error === true ||
asString(payload.status).toLowerCase() === "error" ||
asString(payload.status).toLowerCase() === "failed" ||
asString(payload.status).toLowerCase() === "cancelled" ||
payload.error !== undefined;
const content =
result !== undefined
? isShellTool
? formatShellToolResultForLog(result)
: stringifyUnknown(result)
: `${toolName} completed`;
return [{
kind: "tool_result",
ts,
toolUseId: callId,
content,
isError,
}];
}
return [{
kind: "system",
ts,
text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`,
}];
}
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
const normalized = normalizeCursorStreamLine(line);
if (!normalized.line) return [];
const parsed = asRecord(safeJsonParse(normalized.line));
if (!parsed) {
return [{ kind: "stdout", ts, text: normalized.line }];
}
const type = asString(parsed.type);
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {
const sessionId =
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID);
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
}
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
}
if (type === "assistant") {
const entries = parseAssistantMessage(parsed.message, ts);
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
}
if (type === "user") {
return parseUserMessage(parsed.message, ts);
}
if (type === "thinking") {
const textFromTopLevel = asString(parsed.text);
const textFromDelta = asString(asRecord(parsed.delta)?.text);
const text = textFromTopLevel.length > 0 ? textFromTopLevel : textFromDelta;
const subtype = asString(parsed.subtype).trim().toLowerCase();
const isDelta = subtype === "delta" || asRecord(parsed.delta) !== null;
if (!text.trim()) return [];
return [{ kind: "thinking", ts, text: isDelta ? text : text.trim(), ...(isDelta ? { delta: true } : {}) }];
}
if (type === "tool_call") {
return parseCursorToolCallEvent(parsed, ts);
}
if (type === "result") {
const usage = asRecord(parsed.usage);
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
const cachedTokens = asNumber(
usage?.cached_input_tokens,
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
);
const subtype = asString(parsed.subtype, "result");
const errors = Array.isArray(parsed.errors)
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
: [];
const errorText = asString(parsed.error).trim();
if (errorText) errors.push(errorText);
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
return [{
kind: "result",
ts,
text: asString(parsed.result),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
subtype,
isError,
errors,
}];
}
if (type === "error") {
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line;
return [{ kind: "stderr", ts, text: message }];
}
// Compatibility with older stream-json event shapes.
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
}
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
const toolName = asString(part?.tool, "tool");
const state = asRecord(part?.state);
const input = state?.input ?? {};
const output = asString(state?.output).trim();
const status = asString(state?.status).trim();
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exitCode) && exitCode !== 0);
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts,
name: toolName,
input,
},
];
if (status || output) {
const lines: string[] = [];
if (status) lines.push(`status: ${status}`);
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
if (output) {
if (lines.length > 0) lines.push("");
lines.push(output);
}
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: lines.join("\n").trim() || "tool completed",
isError,
});
}
return entries;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason);
return [{
kind: "result",
ts,
text: reason,
inputTokens: asNumber(tokens?.input),
outputTokens: asNumber(tokens?.output),
cachedTokens: asNumber(cache?.read),
costUsd: asNumber(part?.cost),
subtype: reason || "step_finish",
isError: reason === "error" || reason === "failed",
errors: [],
}];
}
return [{ kind: "stdout", ts, text: normalized.line }];
}

View File

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

View File

@@ -0,0 +1,72 @@
# OpenClaw Gateway Adapter
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
## Transport
This adapter always uses WebSocket gateway transport.
- URL must be `ws://` or `wss://`
- Connect flow follows gateway protocol:
1. receive `connect.challenge`
2. send `req connect` (protocol/client/auth/device payload)
3. send `req agent`
4. wait for completion via `req agent.wait`
5. stream `event agent` frames into Paperclip logs/transcript parsing
## Auth Modes
Gateway credentials can be provided in any of these ways:
- `authToken` / `token` in adapter config
- `headers.x-openclaw-token`
- `headers.x-openclaw-auth` (legacy)
- `password` (shared password mode)
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
## Device Auth
By default the adapter sends a signed `device` payload in `connect` params.
- set `disableDeviceAuth=true` to omit device signing
- set `devicePrivateKeyPem` to pin a stable signing key
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once.
## Session Strategy
The adapter supports the same session routing model as HTTP OpenClaw mode:
- `sessionKeyStrategy=issue|fixed|run`
- `sessionKey` is used when strategy is `fixed`
Resolved session key is sent as `agent.sessionKey`.
## Payload Mapping
The agent request is built as:
- required fields:
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
- `idempotencyKey` (Paperclip `runId`)
- `sessionKey` (resolved strategy)
- optional additions:
- all `payloadTemplate` fields merged in
- `agentId` from config if set and not already in template
## Timeouts
- `timeoutSec` controls adapter-level request budget
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
## Log Format
Structured gateway event logs use:
- `[openclaw-gateway] ...` for lifecycle/system logs
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates.

View File

@@ -0,0 +1,109 @@
# OpenClaw Gateway Onboarding and Test Plan
## Scope
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`)
## Requirements
1. OpenClaw test image must be stock/clean every run.
2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed).
3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`.
4. Invite/access flow must be secure:
- invite prompt endpoint is board-permission protected
- CEO agent is allowed to invoke the invite prompt endpoint for their own company
5. E2E pass criteria must include the 3 functional task cases.
## Current Product Flow
1. Board/CEO opens company settings.
2. Click `Generate OpenClaw Invite Prompt`.
3. Paste generated prompt into OpenClaw chat.
4. OpenClaw submits invite acceptance with:
- `adapterType: "openclaw_gateway"`
- `agentDefaultsPayload.url: ws://... | wss://...`
- `agentDefaultsPayload.headers["x-openclaw-token"]`
5. Board approves join request.
6. OpenClaw claims API key and installs/uses Paperclip skill.
7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key.
## Technical Contract (Gateway)
`agentDefaultsPayload` minimum:
```json
{
"url": "ws://127.0.0.1:18789",
"headers": { "x-openclaw-token": "<gateway-token>" }
}
```
Recommended fields:
```json
{
"paperclipApiUrl": "http://host.docker.internal:3100",
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "issue",
"role": "operator",
"scopes": ["operator.admin"]
}
```
Security/pairing defaults:
- `disableDeviceAuth`: default false
- `devicePrivateKeyPem`: generated during join if missing
## Codex Automation Workflow
### 0) Reset and boot
```bash
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
fi
docker image rm openclaw:local || true
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
```
### 1) Start Paperclip
```bash
pnpm dev --tailscale-auth
curl -fsS http://127.0.0.1:3100/api/health
```
### 2) Invite + join + approval
- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt`
- paste prompt to OpenClaw
- approve join request
- assert created agent:
- `adapterType == openclaw_gateway`
- token header exists and length >= 16
- `devicePrivateKeyPem` exists
### 3) Pairing stabilization
- if first run returns `pairing required`, approve pending device in OpenClaw
- rerun task and confirm success
- assert later runs do not require re-pairing for same agent
### 4) Functional E2E assertions
1. Task assigned to OpenClaw is completed and closed.
2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat).
3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task.
## Manual Smoke Checklist
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
## Regression Gates
Required before merge:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
If full suite is too heavy locally, run at least:
```bash
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
pnpm --filter @paperclipai/server typecheck
pnpm --filter @paperclipai/ui typecheck
pnpm --filter paperclipai typecheck
```

View File

@@ -0,0 +1,52 @@
{
"name": "@paperclipai/adapter-openclaw-gateway",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,23 @@
import pc from "picocolors";
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
if (!debug) {
console.log(line);
return;
}
if (line.startsWith("[openclaw-gateway:event]")) {
console.log(pc.cyan(line));
return;
}
if (line.startsWith("[openclaw-gateway]")) {
console.log(pc.blue(line));
return;
}
console.log(pc.gray(line));
}

View File

@@ -0,0 +1 @@
export { printOpenClawGatewayStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,42 @@
export const type = "openclaw_gateway";
export const label = "OpenClaw Gateway";
export const models: { id: string; label: string }[] = [];
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
Adapter: openclaw_gateway
Use when:
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
Don't use when:
- You only expose OpenClaw HTTP endpoints.
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
Core fields:
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
- authToken (string, optional): shared gateway token override
- password (string, optional): gateway shared password, if configured
Gateway connect identity fields:
- clientId (string, optional): gateway client id (default gateway-client)
- clientMode (string, optional): gateway client mode (default backend)
- clientVersion (string, optional): client version string
- role (string, optional): gateway role (default operator)
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
Session routing fields:
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
`;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,2 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js";

View File

@@ -0,0 +1,317 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "localhost" || value === "127.0.0.1" || value === "::1";
}
function toStringRecord(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") out[key] = entry;
}
return out;
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
return match ? match[1] : null;
}
function tokenFromAuthHeader(rawHeader: string | null): string | null {
if (!rawHeader) return null;
const trimmed = rawHeader.trim();
if (!trimmed) return null;
const match = trimmed.match(/^bearer\s+(.+)$/i);
return match ? nonEmpty(match[1]) : trimmed;
}
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
if (explicit) return explicit;
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
const authHeader =
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
headerMapGetIgnoreCase(headers, "authorization");
return tokenFromAuthHeader(authHeader);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function rawDataToString(data: unknown): string {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
if (Array.isArray(data)) {
return Buffer.concat(
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
).toString("utf8");
}
return String(data ?? "");
}
async function probeGateway(input: {
url: string;
headers: Record<string, string>;
authToken: string | null;
role: string;
scopes: string[];
timeoutMs: number;
}): Promise<"ok" | "challenge_only" | "failed"> {
return await new Promise((resolve) => {
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
const timeout = setTimeout(() => {
try {
ws.close();
} catch {
// ignore
}
resolve("failed");
}, input.timeoutMs);
let completed = false;
const finish = (status: "ok" | "challenge_only" | "failed") => {
if (completed) return;
completed = true;
clearTimeout(timeout);
try {
ws.close();
} catch {
// ignore
}
resolve(status);
};
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(rawDataToString(raw));
} catch {
return;
}
const event = asRecord(parsed);
if (event?.type === "event" && event.event === "connect.challenge") {
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
if (!nonce) {
finish("failed");
return;
}
const connectId = randomUUID();
ws.send(
JSON.stringify({
type: "req",
id: connectId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client",
version: "paperclip-probe",
platform: process.platform,
mode: "probe",
},
role: input.role,
scopes: input.scopes,
...(input.authToken
? {
auth: {
token: input.authToken,
},
}
: {}),
},
}),
);
return;
}
if (event?.type === "res") {
if (event.ok === true) {
finish("ok");
} else {
finish("challenge_only");
}
}
});
ws.on("error", () => {
finish("failed");
});
ws.on("close", () => {
if (!completed) finish("failed");
});
});
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const urlValue = asString(config.url, "").trim();
if (!urlValue) {
checks.push({
code: "openclaw_gateway_url_missing",
level: "error",
message: "OpenClaw gateway adapter requires a WebSocket URL.",
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
let url: URL | null = null;
try {
url = new URL(urlValue);
} catch {
checks.push({
code: "openclaw_gateway_url_invalid",
level: "error",
message: `Invalid URL: ${urlValue}`,
});
}
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
checks.push({
code: "openclaw_gateway_url_protocol_invalid",
level: "error",
message: `Unsupported URL protocol: ${url.protocol}`,
hint: "Use ws:// or wss://.",
});
}
if (url) {
checks.push({
code: "openclaw_gateway_url_valid",
level: "info",
message: `Configured gateway URL: ${url.toString()}`,
});
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
checks.push({
code: "openclaw_gateway_plaintext_remote_ws",
level: "warn",
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
hint: "Prefer wss:// for remote gateways.",
});
}
}
const headers = toStringRecord(config.headers);
const authToken = resolveAuthToken(config, headers);
const password = nonEmpty(config.password);
const role = nonEmpty(config.role) ?? "operator";
const scopes = toStringArray(config.scopes);
if (authToken || password) {
checks.push({
code: "openclaw_gateway_auth_present",
level: "info",
message: "Gateway credentials are configured.",
});
} else {
checks.push({
code: "openclaw_gateway_auth_missing",
level: "warn",
message: "No gateway credentials detected in adapter config.",
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
});
}
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
try {
const probeResult = await probeGateway({
url: url.toString(),
headers,
authToken,
role,
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
timeoutMs: 3_000,
});
if (probeResult === "ok") {
checks.push({
code: "openclaw_gateway_probe_ok",
level: "info",
message: "Gateway connect probe succeeded.",
});
} else if (probeResult === "challenge_only") {
checks.push({
code: "openclaw_gateway_probe_challenge_only",
level: "warn",
message: "Gateway challenge was received, but connect probe was rejected.",
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
});
} else {
checks.push({
code: "openclaw_gateway_probe_failed",
level: "warn",
message: "Gateway probe failed.",
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
});
}
} catch (err) {
checks.push({
code: "openclaw_gateway_probe_error",
level: "warn",
message: err instanceof Error ? err.message : "Gateway probe failed",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,16 @@
export function normalizeOpenClawGatewayStreamLine(rawLine: string): {
stream: "stdout" | "stderr" | null;
line: string;
} {
const trimmed = rawLine.trim();
if (!trimmed) return { stream: null, line: "" };
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
if (!prefixed) {
return { stream: null, line: trimmed };
}
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
const line = (prefixed[2] ?? "").trim();
return { stream, line };
}

View File

@@ -0,0 +1,12 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
ac.timeoutSec = 120;
ac.waitTimeoutMs = 120000;
ac.sessionKeyStrategy = "issue";
ac.role = "operator";
ac.scopes = ["operator.admin"];
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
export { buildOpenClawGatewayConfig } from "./build-config.js";

View File

@@ -0,0 +1,75 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
if (!match) return [{ kind: "stdout", ts, text: line }];
const stream = asString(match[2]).toLowerCase();
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
if (stream === "assistant") {
const delta = asString(data?.delta);
if (delta.length > 0) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
const text = asString(data?.text);
if (text.length > 0) {
return [{ kind: "assistant", ts, text }];
}
return [];
}
if (stream === "error") {
const message = asString(data?.error) || asString(data?.message);
return message ? [{ kind: "stderr", ts, text: message }] : [];
}
if (stream === "lifecycle") {
const phase = asString(data?.phase).toLowerCase();
const message = asString(data?.error) || asString(data?.message);
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
return [{ kind: "stderr", ts, text: message }];
}
}
return [];
}
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const normalized = normalizeOpenClawGatewayStreamLine(line);
if (normalized.stream === "stderr") {
return [{ kind: "stderr", ts, text: normalized.line }];
}
const trimmed = normalized.line.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[openclaw-gateway:event]")) {
return parseAgentEventLine(trimmed, ts);
}
if (trimmed.startsWith("[openclaw-gateway]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: normalized.line }];
}

View File

@@ -1,17 +0,0 @@
# @paperclipai/adapter-openclaw
## 0.2.2
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.2
## 0.2.1
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.1

View File

@@ -1,18 +0,0 @@
import pc from "picocolors";
export function printOpenClawStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
if (!debug) {
console.log(line);
return;
}
if (line.startsWith("[openclaw]")) {
console.log(pc.cyan(line));
return;
}
console.log(pc.gray(line));
}

View File

@@ -1 +0,0 @@
export { printOpenClawStreamEvent } from "./format-event.js";

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