Compare commits

...

368 Commits

Author SHA1 Message Date
Devin Foley
347f38019f docs: clarify all PR template sections are required, not just thinking path
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 23:47:08 -07:00
Devin Foley
25615407a4 docs: update CONTRIBUTING.md to require PR template, Greptile 5/5, and passing tests
Add explicit PR Requirements section referencing .github/PULL_REQUEST_TEMPLATE.md.
Clarify that all PRs must use the template, achieve a 5/5 Greptile score with
all comments addressed, and have passing tests/CI before merge.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 23:46:58 -07:00
Octasoft Ltd
f843a45a84 fix: use sh instead of /bin/sh as shell fallback on Windows (#891)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run shell commands during workspace provisioning (git
worktree creation, runtime services)
> - When `process.env.SHELL` is unset, the code falls back to `/bin/sh`
> - But on Windows with Git Bash, `/bin/sh` doesn't exist as an absolute
path — Git Bash provides `sh` on PATH instead
> - This causes `child_process.spawn` to throw `ENOENT`, crashing
workspace provisioning on Windows
> - This PR extracts a `resolveShell()` helper that uses `$SHELL` when
set, falls back to `sh` (bare) on Windows or `/bin/sh` on Unix
> - The benefit is that agents running on Windows via Git Bash can
provision workspaces without shell resolution errors
## Summary
- `workspace-runtime.ts` falls back to `/bin/sh` when
`process.env.SHELL` is unset
- On Windows, `/bin/sh` doesn't exist → `spawn /bin/sh ENOENT`
- Fix: extract `resolveShell()` helper that uses `$SHELL` when set,
falls back to `sh` on Windows (Git Bash PATH lookup) or `/bin/sh` on
Unix

Three call sites updated to use the new helper.

Fixes #892

## Root cause

When Paperclip spawns shell commands in workspace operations (e.g., git
worktree creation), it uses `process.env.SHELL` if set, otherwise
defaults to `/bin/sh`. On Windows with Git Bash, `$SHELL` is typically
unset and `/bin/sh` is not a valid path — Git Bash provides `sh` on PATH
but not at the absolute `/bin/sh` location. This causes
`child_process.spawn` to throw `ENOENT`.

## Approach

Rather than hard-coding a Windows-specific absolute path (e.g.,
`C:\Program Files\Git\bin\sh.exe`), we use the bare `"sh"` command which
relies on PATH resolution. This works because:
1. Git Bash adds its `usr/bin` directory to PATH, making `sh` resolvable
2. On Unix/macOS, `/bin/sh` remains the correct default (it's the POSIX
standard location)
3. `process.env.SHELL` takes priority when set, so this only affects the
fallback

## Test plan

- [x] 7 unit tests for `resolveShell()`: SHELL set, trimmed, empty,
whitespace-only, linux/darwin/win32 fallbacks
- [x] Run a workspace provision command on Windows with `git_worktree`
strategy
- [x] Verify Unix/macOS is unaffected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@devinfoley.com>
2026-04-02 17:34:26 -07:00
Dotta
36049beeea Merge pull request #2552 from paperclipai/PAPA-42-add-model-used-to-pr-template-and-checklist
feat: add Model Used section to PR template and checklist
2026-04-02 13:47:46 -05:00
Devin Foley
c041fee6fc feat: add Model Used section to PR template and checklist
Add a required "Model Used" section to the PR template so contributors
document which AI model (with version, context window, reasoning mode,
and other capability details) was used for each change. Also adds a
corresponding checklist item.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:32:22 -07:00
Dotta
82290451d4 Merge pull request #2541 from paperclipai/pap-1078-qol-fixes
fix(ui): polish issue detail timelines and attachments
2026-04-02 13:31:12 -05:00
dotta
fb3b57ab1f merge master into pap-1078-qol-fixes
Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 13:14:20 -05:00
Dotta
ca8d35fd99 Merge pull request #2540 from paperclipai/pap-1078-inbox-operator-polish
feat(inbox): add operator search and keyboard controls
2026-04-02 13:02:33 -05:00
Dotta
81a7f79dfd Merge pull request #2539 from paperclipai/pap-1078-workspaces-routines
feat(routines): add workspace-aware routine runs
2026-04-02 13:01:19 -05:00
dotta
ad1ef6a8c6 fix(ui): address final Greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 12:21:35 -05:00
dotta
833842b391 fix(inbox): address Greptile review findings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 12:16:34 -05:00
dotta
fd6cfc7149 fix(routines): address Greptile review findings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 12:09:02 -05:00
dotta
50e9f69010 fix(ui): surface skipped wakeup messages in agent detail
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:52:43 -05:00
dotta
38a0cd275e test(ui): cover routine run variables dialog
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:52:13 -05:00
dotta
bd6d07d0b4 fix(ui): polish issue detail timelines and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:51:40 -05:00
dotta
3ab7d52f00 feat(inbox): add operator search and keyboard controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:45:15 -05:00
dotta
909e8cd4c8 feat(routines): add workspace-aware routine runs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 11:38:57 -05:00
Dotta
36376968af Merge pull request #2527 from paperclipai/PAP-806-telemetry-implementation-in-paperclip-plan
Add app, server, and plugin telemetry plumbing
2026-04-02 11:10:16 -05:00
dotta
29d0e82dce fix: make feedback migration replay-safe after rebase
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:54:56 -05:00
dotta
1c1040e219 test: make cli telemetry test deterministic in CI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:30 -05:00
dotta
0ec8257563 fix: include shared telemetry sources in cli typecheck
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:30 -05:00
dotta
38833304d4 fix: restore cli telemetry config handling in worktrees
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:30 -05:00
dotta
85e6371cb6 fix: use agent role for first heartbeat telemetry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:30 -05:00
dotta
daea94a2ed test: align task-completed telemetry assertion with agent role
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:30 -05:00
dotta
c18b3cb414 fix: use agent role instead of adapter type in task_completed telemetry
The agent.task_completed event was sending adapterType (e.g. "claude_local")
as the agent_role dimension instead of the actual role (e.g. "engineer").

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:47:30 -05:00
dotta
af844b778e Add plugin telemetry bridge capability
Expose telemetry.track through the plugin SDK and server host bridge, forward plugin-prefixed events into the shared telemetry client, and demonstrate the capability in the kitchen sink example.\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:29 -05:00
dotta
53dbcd185e fix: align telemetry client payload and dimensions with backend schema
Restructure the TelemetryClient to send the correct backend envelope
format ({app, schemaVersion, installId, events: [{name, occurredAt, dimensions}]})
instead of the old per-event format. Update all event dimension names
to match the backend registry (agent_role, adapter_type, error_code, etc.).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:29 -05:00
dotta
f16de6026d fix: add periodic flush and graceful shutdown for server-side telemetry
The TelemetryClient only flushed at 50 events, so the server silently
lost all queued telemetry on restart. Add startPeriodicFlush/stop methods
to TelemetryClient, wire up 60s periodic flush in server initTelemetry,
and flush on SIGTERM/SIGINT before exit.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:29 -05:00
dotta
34044cdfce feat: implement app-side telemetry sender
Add the shared telemetry sender, wire the CLI/server emit points,
and cover the config and completion behavior with tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:47:29 -05:00
Dotta
ca5659f734 Merge pull request #2529 from paperclipai/PAP-880-thumbs-capture-for-evals-feature-pr
Add feedback voting and thumbs capture flow
2026-04-02 10:44:50 -05:00
dotta
d12e3e3d1a Fix feedback review findings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 10:03:07 -05:00
dotta
c0d0d03bce Add feedback voting and thumbs capture flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 09:11:49 -05:00
Dotta
3db6bdfc3c Merge pull request #2414 from aronprins/skill/routines
feat(skills): add paperclip-routines skill
2026-04-02 06:37:44 -05:00
dotta
6524dbe08f fix(skills): move routines docs into paperclip references
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 06:28:04 -05:00
Dotta
2c1883fc77 Merge pull request #2449 from statxc/feat/github-enterprise-url-support
feat: GitHub enterprise url support
2026-04-02 06:07:44 -05:00
Aron Prins
4abd53c089 fix(skills): tighten api-reference table descriptions to match existing style
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:00:53 +02:00
Aron Prins
3c99ab8d01 chore: improve api documentation and implementing routines properly. 2026-04-02 10:52:52 +02:00
Devin Foley
9d6d159209 chore: add package files to CODEOWNERS for dependency review (#2476)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The GitHub repository uses CODEOWNERS to enforce review requirements on critical files
> - Currently only release scripts and CI config are protected — package manifests are not
> - Dependency changes (package.json, lockfile) can introduce supply-chain risk if merged without review
> - This PR adds all package files to CODEOWNERS
> - The benefit is that any dependency change now requires explicit approval from maintainers

## What Changed

- Added root package manifest files (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.npmrc`) to CODEOWNERS
- Added all 19 workspace `package.json` files (`cli/`, `server/`, `ui/`, `packages/*`) to CODEOWNERS
- All entries owned by `@cryppadotta` and `@devinfoley`, consistent with existing release infrastructure ownership

## Verification

- `gh api repos/paperclipai/paperclip/contents/.github/CODEOWNERS?ref=PAPA-41-add-package-files-to-codeowners` to inspect the file
- Open a test PR touching any `package.json` and confirm GitHub requests review from the listed owners

## Risks

- Low risk. CODEOWNERS only adds review requirements — does not block merges unless branch protection enforces it. New packages added in the future will need a corresponding CODEOWNERS entry.

## Checklist

- [x] I have included a thinking path that traces from project context to this change
- [x] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 20:32:39 -07:00
Devin Foley
26069682ee fix: copy button fallback for non-secure contexts (#2472)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The UI serves agent management pages including an instructions editor with copy-to-clipboard buttons
> - The Clipboard API (`navigator.clipboard.writeText`) requires a secure context (HTTPS or localhost)
> - Users accessing the UI over HTTP on a LAN IP get "Copy failed" when clicking the copy icon
> - This pull request adds an `execCommand("copy")` fallback in `CopyText` for non-secure contexts
> - The benefit is that copy buttons work reliably regardless of whether the page is served over HTTPS or plain HTTP

## What Changed

- `ui/src/components/CopyText.tsx`: Added `window.isSecureContext` check before using `navigator.clipboard`. When unavailable, falls back to creating a temporary `<textarea>`, selecting its content, and using `document.execCommand("copy")`. The return value is checked and the DOM element is cleaned up via `try/finally`.

## Verification

- Access the UI over HTTP on a non-localhost IP (e.g. `http://[local-ip]:3100`)
- Navigate to any agent's instructions page → Advanced → click the copy icon next to Root path
- Should show "Copied!" tooltip and the path should be on the clipboard

## Risks

- Low risk. `execCommand("copy")` is deprecated in the spec but universally supported by all major browsers. The fallback only activates in non-secure contexts where the modern API is unavailable. If/when HTTPS is enabled, the modern `navigator.clipboard` path is used automatically.

## Checklist

- [x] I have included a thinking path that traces from project context to this change
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before requesting merge
2026-04-01 20:16:52 -07:00
Devin Foley
1e24e6e84c fix: auto-detect default branch for worktree creation when baseRef not configured (#2463)
* fix: auto-detect default branch for worktree creation when baseRef not configured

When creating git worktrees, if no explicit baseRef is configured in
the project workspace strategy and no repoRef is set, the system now
auto-detects the repository's default branch instead of blindly
falling back to "HEAD".

Detection strategy:
1. Check refs/remotes/origin/HEAD (set by git clone / remote set-head)
2. Fall back to probing refs/remotes/origin/main, then origin/master
3. Final fallback: HEAD (preserves existing behavior)

This prevents failures like "fatal: invalid reference: main" when a
project's workspace strategy has no baseRef and the repo uses a
non-standard default branch name.

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

* fix: address Greptile review - fix misleading comment and add symbolic-ref test

- Corrected comment to clarify that the existing test exercises the
  heuristic fallback path (not symbolic-ref)
- Added new test case that explicitly sets refs/remotes/origin/HEAD
  via `git remote set-head` to exercise the symbolic-ref code path

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 18:00:49 -07:00
statxc
9d89d74d70 refactor: rename URL validators to looksLikeRepoUrl 2026-04-01 23:21:22 +00:00
statxc
056a5ee32a fix(ui): render agent capabilities field in org chart cards (#2349)
* fix(ui): render agent capabilities field in org chart cards

Closes #2209

* Update ui/src/pages/OrgChart.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-01 15:46:44 -07:00
Devin Foley
dedd972e3d Fix inbox ordering: self-touched issues no longer sink to bottom (#2144)
issueLastActivityTimestamp() returned 0 for issues where the user was
the last to touch them (myLastTouchAt >= updatedAt) and no external
comment existed. This pushed those items to the bottom of the inbox
list regardless of how recently they were updated.

Now falls back to updatedAt instead, so recently updated items sort
to the top of the Recent tab as expected.

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 14:52:53 -07:00
statxc
6a7830b07e fix: add HTTPS protocol check to server-side GitHub URL parsers 2026-04-01 21:27:10 +00:00
statxc
f9cebe9b73 fix: harden GHE URL detection and extract shared GitHub helpers 2026-04-01 21:05:48 +00:00
statxc
9e1ee925cd feat: support GitHub Enterprise URLs for skill and company imports 2026-04-01 20:53:41 +00:00
Dotta
6c2c63e0f1 Merge pull request #2328 from bittoby/fix/project-slug-collision
Fix: project slug collisions for non-English names (#2318)
2026-04-01 09:34:23 -05:00
Dotta
461779a960 Merge pull request #2430 from bittoby/fix/add-gemini-local-to-adapter-types
fix: add gemini_local to AGENT_ADAPTER_TYPES validation enum
2026-04-01 09:18:39 -05:00
bittoby
6aa3ead238 fix: add gemini_local to AGENT_ADAPTER_TYPES validation enum 2026-04-01 14:07:47 +00:00
Dotta
e0f64c04e7 Merge pull request #2407 from radiusred/chore/docker-improvements
chore(docker): improve base image and organize docker files
2026-04-01 08:14:55 -05:00
Aron Prins
e5b2e8b29b fix(skills): address greptile review on paperclip-routines skill
- Add missing `description` field to the Creating a Routine field table
- Document optional `label` field available on all trigger kinds
2026-04-01 13:56:10 +02:00
Aron Prins
62d8b39474 feat(skills): add paperclip-routines skill
Adds a new skill that documents how to create and manage Paperclip
routines — recurring tasks that fire on a schedule, webhook, or API
call and dispatch an execution issue to the assigned agent.
2026-04-01 13:49:11 +02:00
Cody (Radius Red)
420cd4fd8d chore(docker): improve base image and organize docker files
- Add wget, ripgrep, python3, and GitHub CLI (gh) to base image
- Add OPENCODE_ALLOW_ALL_MODELS=true to production ENV
- Move compose files, onboard-smoke Dockerfile to docker/
- Move entrypoint script to scripts/docker-entrypoint.sh
- Add Podman Quadlet unit files (pod, app, db containers)
- Add docker/README.md with build, compose, and quadlet docs
- Add scripts/docker-build-test.sh for local build validation
- Update all doc references for new file locations
- Keep main Dockerfile at project root (no .dockerignore changes needed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 11:36:27 +00:00
Dotta
5b479652f2 Merge pull request #2327 from radiusred/fix/env-var-plain-to-secret-data-loss
fix(ui): preserve env var when switching type from Plain to Secret
2026-03-31 11:37:07 -05:00
bittoby
99296f95db fix: append short UUID suffix to project slugs when non-ASCII characters are stripped to prevent slug collisions 2026-03-31 16:35:30 +00:00
Cody (Radius Red)
92e03ac4e3 fix(ui): prevent dropdown snap-back when switching env var to Secret
Address Greptile review feedback: the plain-value fallback in emit()
caused the useEffect sync to re-run toRows(), which mapped the plain
binding back to source: "plain", snapping the dropdown back.

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

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

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

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

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

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

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

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

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

Includes 6 dedicated unit tests for deriveTaskKeyWithHeartbeatFallback.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: fix stale concept count and frontmatter summary

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

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

---------

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:39:46 -07:00
Dotta
af5b980362 Merge pull request #1857 from paperclipai/PAP-878-create-a-mine-tab-in-inbox
Add a Mine tab and archive flow to inbox
2026-03-26 16:21:47 -05:00
dotta
2e563ccd50 Move unread/archive column to the left for non-issue inbox items
Repositions the unread dot and archive X button to the leading
(left) side of approval, failed run, and join request rows,
matching the visual alignment of IssueRow where the unread slot
appears first due to CSS flex ordering.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* ci: guard against empty workspace roots in Dockerfile check

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

* ci: guard against empty deps stage extraction

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

* ci: deduplicate find results from overlapping workspace roots

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 07:42:38 -05:00
Matt Van Horn
d0e01d2863 fix(server): include x-forwarded-host in board mutation origin check
Behind a reverse proxy with a custom port (e.g. Caddy on :3443), the
browser sends an Origin header that includes the port, but the board
mutation guard only read the Host header which often omits the port.
This caused a 403 "Board mutation requires trusted browser origin"
for self-hosted deployments behind reverse proxies.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-03-24 09:40:55 +02:00
Devin Foley
59e29afab5 Merge pull request #1672 from paperclipai/fix/docker-plugin-sdk
fix(docker): add plugin-sdk to Dockerfile build
2026-03-23 20:05:56 -07:00
Devin Foley
fd4df4db48 fix(docker): add plugin-sdk to Dockerfile build
The plugin framework landed without updating the Dockerfile. The
server now imports @paperclipai/plugin-sdk, so the deps stage needs
its package.json for install and the build stage needs to compile
it before building the server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:58:59 -07:00
Devin Foley
8ae954bb8f Merge pull request #1666 from paperclipai/chore/pr-template
chore: add GitHub PR template
2026-03-23 19:53:45 -07:00
Dotta
32c76e0012 Merge pull request #1670 from paperclipai/pr/pap-803-mention-aware-link-node
Extract mention-aware link node helper and add tests
2026-03-23 21:33:49 -05:00
Dotta
70bd55a00f Merge pull request #1669 from paperclipai/pr/pap-803-agent-instructions-tab-reset
Fix instructions tab state on agent switch
2026-03-23 21:27:51 -05:00
Dotta
f92d2c3326 Merge pull request #1668 from paperclipai/pr/pap-803-imported-agent-frontmatter
Fix imported agent bundle frontmatter leakage
2026-03-23 21:27:42 -05:00
dotta
a3f4e6f56c Preserve prompts panel width on agent switch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:58:19 -05:00
dotta
08bdc3d28e Handle nested imported AGENTS edge case
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:56:34 -05:00
dotta
7c54b6e9e3 Extract mention-aware link node helper and add tests
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:46:19 -05:00
dotta
a346ad2a73 Fix instructions tab state on agent switch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:45:11 -05:00
dotta
e4e5b61596 Fix imported agent bundle frontmatter leakage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:43:20 -05:00
Dotta
eeb7e1a91a Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
2026-03-23 19:45:05 -05:00
Dotta
f2637e6972 Merge pull request #1654 from paperclipai/pr/pap-795-agent-runtime
fix(runtime): improve agent recovery and heartbeat operations
2026-03-23 19:44:51 -05:00
dotta
c8f8f6752f fix: address latest Greptile runtime review 2026-03-23 19:43:50 -05:00
dotta
87b3cacc8f Address valid Greptile portability follow-ups 2026-03-23 19:42:58 -05:00
github-actions[bot]
4096db8053 chore(lockfile): refresh pnpm-lock.yaml (#1667)
Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-03-24 00:29:19 +00:00
Dotta
fa084e1a16 Merge pull request #1653 from paperclipai/pr/pap-795-ui-polish
fix(ui): polish issue and agent surfaces
2026-03-23 19:28:50 -05:00
dotta
22067c7d1d revert: drop PR workflow lockfile refresh 2026-03-23 19:26:33 -05:00
dotta
85d2c54d53 fix(ci): refresh lockfile in PR jobs 2026-03-23 19:23:10 -05:00
Devin Foley
5222a49cc3 chore: expand thinking path placeholder for depth
Address Greptile feedback — the sparse 3-line placeholder could
lead to shallow thinking paths. Expanded to 6 lines with guiding
brackets and added "Aim for 5–8 steps" hint in the comment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:12:07 -07:00
Devin Foley
36574bd9c6 chore: add GitHub PR template
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:08:04 -07:00
dotta
2cc2d4420d Remove lockfile changes from UI polish PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 19:03:50 -05:00
Dotta
7576c5ecbc Update ui/src/pages/Auth.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-23 19:00:34 -05:00
Devin Foley
dd1d9bed80 fix(server): check MIGRATION_AUTO_APPLY before MIGRATION_PROMPT
PAPERCLIP_MIGRATION_PROMPT=never was checked before
PAPERCLIP_MIGRATION_AUTO_APPLY=true, causing auto-apply to never
trigger when both env vars are set (as in dev:watch). Swap the
check order so AUTO_APPLY takes precedence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:14:14 -07:00
dotta
92c29f27c3 Address Greptile review on portability PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 17:23:59 -05:00
dotta
55b26ed590 Address Greptile review on agent runtime PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 17:18:17 -05:00
dotta
6960ab1106 Address Greptile review on UI polish PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 17:16:10 -05:00
dotta
c3f4e18a5e Keep sidebar ordering with portability branch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 17:02:43 -05:00
dotta
a3f568dec7 Improve generated company org chart assets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:58:07 -05:00
dotta
6f1ce3bd60 Document imported heartbeat defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:58:07 -05:00
dotta
159c5b4360 Preserve sidebar order in company portability
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:59 -05:00
dotta
b5fde733b0 Open imported company after import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:39 -05:00
dotta
f9927bdaaa Disable imported timer heartbeats
Prevent company imports from re-enabling scheduler heartbeats on imported agents and cover both new-company and existing-company import flows in portability tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:39 -05:00
dotta
dcead97650 Fix company zip imports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:39 -05:00
dotta
9786ebb7ba Revert "Add companies.sh import wrapper"
This reverts commit 17876ec1dc65a9150488874d79fc2fcc087c13ae.
2026-03-23 16:57:39 -05:00
dotta
66d84ccfa3 Add companies.sh import wrapper
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
56a39fea3d Add importing & exporting company guide
Documents the `paperclipai company export` and `paperclipai company import`
CLI commands, covering package format, all options, target modes, collision
strategies, GitHub sources, interactive selection, and API endpoints.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:38 -05:00
dotta
2a6e1cf1fc Fix imported GitHub skill file paths
Normalize GitHub skill directories for blob/file imports and when reading legacy stored metadata so imported SKILL.md files resolve correctly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
c02dc73d3c Confirm company imports after preview
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
06f5632d1a Polish import adapter defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
1246ccf250 Add nested import picker
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
a339b488ae fix: dedupe company skill inventory refreshes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
ac376d0e5e Add TUI import summaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
220946b2a1 Default recurring task exports to checked
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
c41dd2e393 Reduce portability warning fan-out
Infer portable repo metadata from local git workspaces when repoUrl is missing, and collapse repeated task workspace export warnings into a single summary per missing workspace.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
2e76a2a554 Add routine support to recurring task portability
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:38 -05:00
dotta
8fa4b6a5fb added a script to generate company assets 2026-03-23 16:57:38 -05:00
dotta
d8b408625e fix providers 2026-03-23 16:57:38 -05:00
dotta
19154d0fec Clarify Codex instruction sources
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
c0c1fd17cb Add "Disable All" button to heartbeats settings page
Adds a destructive-variant button at the top of the heartbeats page that
disables timer heartbeats for all agents at once. The button only appears
when at least one agent has heartbeats enabled, and shows a loading state
while processing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:33 -05:00
dotta
2daae758b1 Include all agents on heartbeats page regardless of interval config
Agents without a heartbeat interval configured (intervalSec=0) were
filtered out, making them invisible on the instance heartbeats page.
This prevented managing heartbeats for agents that hadn't been
configured yet (e.g. donchitos company agents).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
43b21c6033 Ignore .paperclip paths in restart tracking
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
0bb1ee3caa Recover agent instructions from disk
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
3b2cb3a699 Show all companies' agents on instance heartbeats page
The /instance/scheduler-heartbeats endpoint was filtering agents by the
requesting user's company memberships, which meant non-member companies
(like donchitos) were hidden. Since this is an instance-level admin page,
it should show all agents across all companies.

- Added assertInstanceAdmin to authz.ts for reuse
- Replaced assertBoard + company filter with assertInstanceAdmin
- Removed the companyIds-based WHERE clause since instance admins see all

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
1adfd30b3b fix: recover managed agent instructions from disk
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
a315838d43 fix: preserve agent instructions on adapter switch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
75c7eb3868 Ignore test-only paths in dev restart tracking
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
eac3f3fa69 Honor explicit failed-run session resume
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
02c779b41d Use issue participation for agent history
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
5a1e17f27f Fix issue workspace reuse after isolation
Persist realized isolated/operator workspaces back onto the issue as reusable workspaces so later runs stay on the same workspace, and update the issue workspace picker to present realized isolated workspaces as existing workspaces.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
e0d2c4bddf Ignore .paperclip in dev restart detection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:33 -05:00
dotta
d73c8df895 fix: improve pill contrast by using WCAG contrast ratios on composited backgrounds
Pills with semi-transparent backgrounds were using raw color luminance to pick
text color, ignoring the page background showing through. This caused unreadable
text on dark themes for mid-luminance colors like orange. Now composites the
rgba background over the actual page bg (dark/light) before computing WCAG
contrast ratios, and centralizes the logic in a shared color-contrast utility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:27 -05:00
dotta
e73bc81a73 fix: prevent documents row from causing horizontal scroll on mobile
- Shorten button labels ("New document" → "New", "Upload attachment" → "Upload") on small screens
- Add min-w-0 and shrink-0 to flex containers and items to prevent overflow
- Truncate document revision text on narrow viewports
- Mark chevron, key badge, and action buttons as shrink-0

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
0b960b0739 Suppress same-page issue toasts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
bdecb1bad2 Sort agents alphabetically by name in all views
Sort the flat list view, org tree view, and sidebar agents list
alphabetically by name using localeCompare.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
e61f00d4c1 Add missing data-slot="toggle" to Routines toggle buttons
The initial mobile toggle fix (afc3d7ec) missed 2 toggle switches in
RoutineDetail.tsx and Routines.tsx. Without the attribute, these toggles
would still inflate to 44px on touch devices via the @media (pointer: coarse)
rule.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
42c8d9b660 Fix oversized toggle switches on mobile
The global @media (pointer: coarse) rule was forcing min-height: 44px on
toggle button elements, inflating them from 20px to 44px. Added
data-slot="toggle" to all 10 toggle buttons and a CSS override to reset
their min-height, keeping the parent row as the touch target.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
bd0b76072b Fix atomic markdown mention deletion
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
db42adf1bf Make agent instructions tab responsive on mobile
On mobile, the two-panel file browser + editor layout now stacks
vertically with a toggleable file panel. The draggable separator is
hidden, and selecting a file auto-closes the panel to maximize
editor space.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:27 -05:00
dotta
0e8e162cd5 Fix mention pills by allowing custom URL schemes in Lexical LinkNode
The previous fix (validateUrl on linkPlugin) only affected the link dialog,
not the markdown-to-Lexical import path. Lexical's LinkNode.sanitizeUrl()
converts agent:// and project:// URLs to about:blank because they aren't
in its allowlist. Override the prototype method to preserve these schemes
so mention chips render correctly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
49ace2faf9 Allow custom markdown mention links in editor
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
8232456ce8 Fix markdown mention chips
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
dotta
cd7c6ee751 Fix login form not being detected by 1Password
Add name, id, and htmlFor attributes to form inputs and a method/action
to the form element so password managers can properly identify the login
form fields.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:27 -05:00
dotta
f8dd4dcb30 Reduce monospace font size from 1.1em to 1em
1.1em was too large per feedback — settle on 1em for all markdown
monospace contexts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:27 -05:00
dotta
0b9f00346b Increase monospace font size and add dark mode background for inline code
Bump monospace font-size from 0.78em to 1.1em across all markdown
contexts (editor, code blocks, inline code). Add subtle gray
background (#ffffff0f) for inline code in dark mode.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:57:27 -05:00
dotta
ef0846e723 Remove priority icon from issue rows across the app
Priority is still supported as a feature (editable in issue properties,
used in filters), but no longer shown prominently in every issue row.
Affects inbox, issues list, my issues, and dashboard pages.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 16:57:27 -05:00
Dotta
3a79d94050 Merge pull request #1380 from DanielSousa/feature/change-reports-to
feat(ui): edit and hire with Reports to picker
2026-03-23 15:08:54 -05:00
Dotta
b5610f66a6 Merge pull request #1382 from lucas-stellet/fix/pi-local-missing-from-isLocal
fix: add pi_local to remaining isLocal guards in UI
2026-03-23 15:06:20 -05:00
Dotta
119dd0eaa0 Merge pull request #542 from albttx/dockerize
chore(ci): deploy docker image
2026-03-23 15:03:36 -05:00
Dotta
080c9e415d Merge pull request #1635 from paperclipai/pr/pap-768-board-cli-auth
Add browser-based board CLI auth flow
2026-03-23 08:47:32 -05:00
dotta
7f9a76411a Address Greptile review on board CLI auth
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:46:05 -05:00
dotta
01b6b7e66a fix: make cli auth migration 0044 idempotent
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:46:05 -05:00
dotta
298713fae7 Fix duplicate auth login company flag
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:46:05 -05:00
dotta
37c2c4acc4 Add browser-based board CLI auth flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:46:05 -05:00
Dotta
1376fc8f44 Merge pull request #1631 from paperclipai/pr/pap-768-company-import-safe-imports
Improve company import CLI flows and safe existing-company routes
2026-03-23 08:25:33 -05:00
Dotta
e6801123ca Merge pull request #1632 from paperclipai/pr/pap-768-merge-history
Add merge-history project import option
2026-03-23 08:22:27 -05:00
dotta
f23d611d0c Route existing-company CLI imports through safe routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:14:51 -05:00
dotta
e6df9fa078 Support GitHub shorthand refs for company import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:14:51 -05:00
dotta
5a73556871 Use positional source arg for company import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:14:51 -05:00
dotta
e204e03fa6 Add CLI company import export e2e test
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 08:14:51 -05:00
Lucas Stellet
e37e9df0d1 fix: address greptile review — add pi_local to effectiveAdapterCommand and adapterLabels
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:01:52 -03:00
Lucas Stellet
5e414ff4df fix: add pi_local to isLocalAdapter and ENABLED_INVITE_ADAPTERS guards
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:57:17 -03:00
Daniel Sousa
4587627f3c refactor: improve layout and truncation in ReportsToPicker
- Enhanced button and text elements to ensure proper overflow handling and truncation for agent names and statuses.
- Adjusted class names for better responsiveness and visual consistency, particularly for unknown and terminated managers.
2026-03-20 20:33:36 +00:00
Daniel Sousa
17b6f6c8f7 fix: enhance ReportsToPicker to handle unknown and terminated managers
- Added handling for cases where the selected manager is terminated, displaying a distinct style and message.
- Introduced a new state for unknown managers, providing user feedback when the saved manager is missing.
- Improved layout for displaying current manager status, ensuring clarity in the UI.
2026-03-20 20:32:03 +00:00
Daniel Sousa
de10269d10 fix: update ReportsToPicker to display terminated status and improve layout
- Modified the display of the current agent's name to include a "(terminated)" suffix if the agent's status is terminated.
- Adjusted button layout to ensure proper text truncation and overflow handling for agent names and roles.
2026-03-20 20:30:07 +00:00
Daniel Sousa
dfb83295de Merge branch 'master' into feature/change-reports-to 2026-03-20 20:13:19 +00:00
Daniel Sousa
61f53b6471 feat: add ReportsToPicker for agent management
- Introduced ReportsToPicker component in AgentConfigForm and NewAgent pages to allow selection of an agent's manager.
- Updated organizational structure documentation to reflect the ability to change an agent's manager post-creation.
- Enhanced error handling in ConfigurationTab to provide user feedback on save failures.
2026-03-20 20:06:19 +00:00
Paperclip Dev
47449152ac fix(issues): normalize HTML entities in @mention tokens before agent lookup (#1255)
Rich-text comments store entities like &#x20; after @names; strip them before matching agents so issue_comment_mentioned and wake injection work.

Made-with: Cursor
2026-03-20 16:38:55 +00:00
Genie
59b1d1551a fix: Vite HMR WebSocket for reverse proxy + WS StrictMode guard
When running behind a reverse proxy (e.g. Caddy), the live-events
WebSocket would fail to connect because it constructed the URL from
window.location without accounting for proxy routing.

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

- Replace deprecated apple-mobile-web-app-capable meta tag
- Guard WS connect with mounted flag to prevent StrictMode double-open
- Use protocol-relative WebSocket URL derivation for proxy compatibility
2026-03-17 07:09:00 -03:00
albttx
b1e2a5615b fix: recover @greptile-apps errors 2026-03-16 10:00:23 +00:00
Albert Le Batteux
b535860a50 Update .github/workflows/docker.yml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-16 10:00:23 +00:00
Albert Le Batteux
2b478764a9 Update .github/workflows/docker.yml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-16 10:00:23 +00:00
albttx
88cc8e495c chore(ci): deploy docker image 2026-03-16 10:00:23 +00:00
Kevin Mok
432d7e72fa Merge upstream/master into add-gpt-5-4-xhigh-effort 2026-03-08 12:10:59 -05:00
Kevin Mok
666ab53648 Remove redundant opencode model assertion 2026-03-05 19:55:15 -06:00
Kevin Mok
314288ff82 Add gpt-5.4 fallback and xhigh effort options 2026-03-05 18:59:42 -06:00
428 changed files with 105726 additions and 3478 deletions

7
.github/CODEOWNERS vendored
View File

@@ -8,3 +8,10 @@ scripts/rollback-latest.sh @cryppadotta @devinfoley
doc/RELEASING.md @cryppadotta @devinfoley
doc/PUBLISHING.md @cryppadotta @devinfoley
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley
# Package files — dependency changes require review
# package.json matches recursively at all depths (covers root + all workspaces)
package.json @cryppadotta @devinfoley
pnpm-lock.yaml @cryppadotta @devinfoley
pnpm-workspace.yaml @cryppadotta @devinfoley
.npmrc @cryppadotta @devinfoley

65
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,65 @@
## Thinking Path
<!--
Required. Trace your reasoning from the top of the project down to this
specific change. Start with what Paperclip is, then narrow through the
subsystem, the problem, and why this PR exists. Use blockquote style.
Aim for 58 steps. See CONTRIBUTING.md for full examples.
-->
> - Paperclip orchestrates AI agents for zero-human companies
> - [Which subsystem or capability is involved]
> - [What problem or gap exists]
> - [Why it needs to be addressed]
> - This pull request ...
> - The benefit is ...
## What Changed
<!-- Bullet list of concrete changes. One bullet per logical unit. -->
-
## Verification
<!--
How can a reviewer confirm this works? Include test commands, manual
steps, or both. For UI changes, include before/after screenshots.
-->
-
## Risks
<!--
What could go wrong? Mention migration safety, breaking changes,
behavioral shifts, or "Low risk" if genuinely minor.
-->
-
## Model Used
<!--
Required. Specify which AI model was used to produce or assist with
this change. Be as descriptive as possible — include:
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
• Context window size if relevant (e.g., 1M context)
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
• Any other relevant capability details (e.g., tool use, code execution)
If no AI model was used, write "None — human-authored".
-->
-
## Checklist
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have specified the model used (with version and capability details)
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all Greptile and reviewer comments before requesting merge

55
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "v*"
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

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

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ server/src/**/*.js.map
server/src/**/*.d.ts
server/src/**/*.d.ts.map
tmp/
feedback-export-*
# Editor / tool temp files
*.tmp

View File

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

View File

@@ -11,8 +11,9 @@ We really appreciate both small fixes and thoughtful larger changes.
- 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
- All tests pass and CI is green
- Greptile score is 5/5 with all comments addressed
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
These almost always get merged quickly when they're clean.
@@ -26,11 +27,26 @@ These almost always get merged quickly when they're clean.
- 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
- All tests passing and CI green
- Greptile score 5/5 with all comments addressed
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
PRs that follow this path are **much** more likely to be accepted, even when they're large.
## PR Requirements (all PRs)
### Use the PR Template
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, and a Checklist.
### Tests Must Pass
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
### Greptile Review
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
## General Rules (both paths)
- Write clear commit messages
@@ -41,7 +57,7 @@ PRs that follow this path are **much** more likely to be accepted, even when the
## Writing a Good PR message
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
### Thinking Path Example 1:

View File

@@ -1,8 +1,23 @@
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
&& mkdir -p -m 755 /etc/apt/keyrings \
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
@@ -20,6 +35,8 @@ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY patches/ patches/
RUN pnpm install --frozen-lockfile
@@ -28,16 +45,22 @@ WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk 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
ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
@@ -45,12 +68,15 @@ ENV NODE_ENV=production \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
OPENCODE_ALLOW_ALL_MODELS=true
VOLUME ["/paperclip"]
EXPOSE 3100
USER node
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]

View File

@@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash
@@ -234,16 +236,40 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
## Roadmap
- ⚪ Get OpenClaw onboarding easier
- Get cloud agents working e.g. Cursor / e2b agents
- ⚪ ClipMart - buy and sell entire agent companies
- Easy agent configurations / easier to understand
- ⚪ Better support for harness engineering
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
- Better docs
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
- Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Scheduled Routines
- Better Budgeting
- ⚪ Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- ⚪ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App
<br/>
## Community & Plugins
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
## Telemetry
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Telemetry is **enabled by default** and can be disabled with any of the following:
| Method | How |
|---|---|
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
| Standard convention | `DO_NOT_TRACK=1` |
| CI environments | Automatically disabled when `CI=true` |
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
## Contributing
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.

292
cli/README.md Normal file
View File

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

View File

@@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },

View File

@@ -0,0 +1,16 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { registerClientAuthCommands } from "../commands/client/auth.js";
describe("registerClientAuthCommands", () => {
it("registers auth commands without duplicate company-id flags", () => {
const program = new Command();
const auth = program.command("auth");
expect(() => registerClientAuthCommands(auth)).not.toThrow();
const login = auth.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
});

View File

@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getStoredBoardCredential,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
} from "../client/board-auth.js";
function createTempAuthPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
return path.join(dir, "auth.json");
}
describe("board auth store", () => {
it("returns an empty store when the file does not exist", () => {
const authPath = createTempAuthPath();
expect(readBoardAuthStore(authPath)).toEqual({
version: 1,
credentials: {},
});
});
it("stores and retrieves credentials by normalized api base", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100/",
token: "token-123",
userId: "user-1",
storePath: authPath,
});
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
apiBase: "http://localhost:3100",
token: "token-123",
userId: "user-1",
});
});
it("removes stored credentials", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "token-123",
storePath: authPath,
});
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});

View File

@@ -15,6 +15,10 @@ function makeCompany(overrides: Partial<Company>): Company {
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
brandColor: null,
logoAssetId: null,
logoUrl: null,

View File

@@ -0,0 +1,502 @@
import { execFile, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const execFileAsync = promisify(execFile);
type ServerProcess = ReturnType<typeof spawn>;
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
const config = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "doctor",
},
database: {
mode: "postgres",
connectionString,
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
embeddedPostgresPort: 54329,
backup: {
enabled: false,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(tempRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(tempRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port,
allowedHostnames: [],
serveUi: false,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(tempRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
},
},
};
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
function createServerEnv(configPath: string, port: number, connectionString: string) {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
env.PAPERCLIP_CONFIG = configPath;
env.DATABASE_URL = connectionString;
env.HOST = "127.0.0.1";
env.PORT = String(port);
env.SERVE_UI = "false";
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
return env;
}
function createCliEnv() {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.PAPERCLIP_DB_BACKUP_ENABLED;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
return env;
}
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
collectTextFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile()) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = readFileSync(absolutePath, "utf8");
}
}
async function stopServerProcess(child: ServerProcess | null) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
}, 5_000);
});
}
async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${baseUrl}${pathname}`, init);
const text = await res.text();
if (!res.ok) {
throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
}
return text ? JSON.parse(text) as T : (null as T);
}
async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const result = await execFileAsync(
"pnpm",
["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
{
cwd: repoRoot,
env: createCliEnv(),
maxBuffer: 10 * 1024 * 1024,
},
);
const stdout = result.stdout.trim();
const jsonStart = stdout.search(/[\[{]/);
if (jsonStart === -1) {
throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
}
return JSON.parse(stdout.slice(jsonStart)) as T;
}
async function waitForServer(
apiBase: string,
child: ServerProcess,
output: { stdout: string[]; stderr: string[] },
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 30_000) {
if (child.exitCode !== null) {
throw new Error(
`paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
try {
const res = await fetch(`${apiBase}/api/health`);
if (res.ok) return;
} catch {
// Server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
let tempRoot = "";
let configPath = "";
let exportDir = "";
let apiBase = "";
let serverProcess: ServerProcess | null = null;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
configPath = path.join(tempRoot, "config", "config.json");
exportDir = path.join(tempRoot, "exported-company");
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
const port = await getAvailablePort();
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
apiBase = `http://127.0.0.1:${port}`;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const output = { stdout: [] as string[], stderr: [] as string[] };
const child = spawn(
"pnpm",
["paperclipai", "run", "--config", configPath],
{
cwd: repoRoot,
env: createServerEnv(configPath, port, tempDb.connectionString),
stdio: ["ignore", "pipe", "pipe"],
},
);
serverProcess = child;
child.stdout?.on("data", (chunk) => {
output.stdout.push(String(chunk));
});
child.stderr?.on("data", (chunk) => {
output.stderr.push(String(chunk));
});
await waitForServer(apiBase, child, output);
}, 60_000);
afterAll(async () => {
await stopServerProcess(serverProcess);
await tempDb?.cleanup();
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("exports a company package and imports it into new and existing companies", async () => {
expect(serverProcess).not.toBeNull();
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
});
const sourceAgent = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/agents`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Export Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You verify company portability.",
},
}),
},
);
const sourceProject = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/projects`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Portability Verification",
status: "in_progress",
}),
},
);
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/issues`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "Validate company import/export",
description: largeIssueDescription,
status: "todo",
projectId: sourceProject.id,
assigneeAgentId: sourceAgent.id,
}),
},
);
const exportResult = await runCliJson<{
ok: boolean;
out: string;
filesWritten: number;
}>(
[
"company",
"export",
sourceCompany.id,
"--out",
exportDir,
"--include",
"company,agents,projects,issues",
],
{ apiBase, configPath },
);
expect(exportResult.ok).toBe(true);
expect(exportResult.filesWritten).toBeGreaterThan(0);
expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');
const importedNew = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"new",
"--new-company-name",
`Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
expect(importedNew.company.action).toBe("created");
expect(importedNew.agents).toHaveLength(1);
expect(importedNew.agents[0]?.action).toBe("created");
const importedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const importedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
const previewExisting = await runCliJson<{
errors: string[];
plan: {
companyAction: string;
agentPlans: Array<{ action: string }>;
projectPlans: Array<{ action: string }>;
issuePlans: Array<{ action: string }>;
};
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--dry-run",
],
{ apiBase, configPath },
);
expect(previewExisting.errors).toEqual([]);
expect(previewExisting.plan.companyAction).toBe("none");
expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);
const importedExisting = await runCliJson<{
company: { id: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--yes",
],
{ apiBase, configPath },
);
expect(importedExisting.company.action).toBe("unchanged");
expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
expect(twiceImportedAgents).toHaveLength(2);
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
expect(twiceImportedProjects).toHaveLength(2);
expect(twiceImportedIssues).toHaveLength(2);
const zipPath = path.join(tempRoot, "exported-company.zip");
const portableFiles: Record<string, string> = {};
collectTextFiles(exportDir, exportDir, portableFiles);
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
const importedFromZip = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
zipPath,
"--target",
"new",
"--new-company-name",
`Zip Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
expect(importedFromZip.company.action).toBe("created");
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
}, 60_000);
});

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { isHttpUrl, isGithubUrl } from "../commands/client/company.js";
import {
isGithubShorthand,
looksLikeRepoUrl,
isHttpUrl,
normalizeGithubImportSource,
} from "../commands/client/company.js";
describe("isHttpUrl", () => {
it("matches http URLs", () => {
@@ -16,16 +21,54 @@ describe("isHttpUrl", () => {
});
});
describe("isGithubUrl", () => {
describe("looksLikeRepoUrl", () => {
it("matches GitHub URLs", () => {
expect(isGithubUrl("https://github.com/org/repo")).toBe(true);
expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
});
it("rejects non-GitHub HTTP URLs", () => {
expect(isGithubUrl("https://example.com/foo")).toBe(false);
it("rejects URLs without owner/repo path", () => {
expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
});
it("rejects local paths", () => {
expect(isGithubUrl("/tmp/my-company")).toBe(false);
expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
});
});
describe("isGithubShorthand", () => {
it("matches owner/repo/path shorthands", () => {
expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
expect(isGithubShorthand("paperclipai/companies")).toBe(true);
});
it("rejects local-looking paths", () => {
expect(isGithubShorthand("./exports/acme")).toBe(false);
expect(isGithubShorthand("/tmp/acme")).toBe(false);
expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
});
});
describe("normalizeGithubImportSource", () => {
it("normalizes shorthand imports to canonical GitHub sources", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
"https://github.com/paperclipai/companies?ref=main&path=gstack",
);
});
it("applies --ref to shorthand imports", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
"https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
);
});
it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
expect(
normalizeGithubImportSource(
"https://github.com/paperclipai/companies/tree/main/gstack",
"release/2026-03-23",
),
).toBe(
"https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
);
});
});

View File

@@ -0,0 +1,44 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("resolveInlineSourceFromPath", () => {
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
);
await writeFile(archivePath, archive);
const resolved = await resolveInlineSourceFromPath(archivePath);
expect(resolved).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
},
});
});
});

View File

@@ -0,0 +1,595 @@
import { describe, expect, it } from "vitest";
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import {
buildCompanyDashboardUrl,
buildDefaultImportAdapterOverrides,
buildDefaultImportSelectionState,
buildImportSelectionCatalog,
buildSelectedFilesFromImportSelection,
renderCompanyImportPreview,
renderCompanyImportResult,
resolveCompanyImportApplyConfirmationMode,
resolveCompanyImportApiPath,
} from "../commands/client/company.js";
describe("resolveCompanyImportApiPath", () => {
it("uses company-scoped preview route for existing-company dry runs", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/preview");
});
it("uses company-scoped apply route for existing-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/apply");
});
it("keeps global routes for new-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "new_company",
}),
).toBe("/api/companies/import/preview");
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "new_company",
}),
).toBe("/api/companies/import");
});
it("throws when an existing-company import is missing a company id", () => {
expect(() =>
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: " ",
})
).toThrow(/require a companyId/i);
});
});
describe("resolveCompanyImportApplyConfirmationMode", () => {
it("skips confirmation when --yes is set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: true,
interactive: false,
json: false,
}),
).toBe("skip");
});
it("prompts in interactive text mode when --yes is not set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: true,
json: false,
}),
).toBe("prompt");
});
it("requires --yes for non-interactive apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: false,
})
).toThrow(/non-interactive terminal requires --yes/i);
});
it("requires --yes for json apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: true,
})
).toThrow(/with --json requires --yes/i);
});
});
describe("buildCompanyDashboardUrl", () => {
it("preserves the configured base path when building a dashboard URL", () => {
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
"https://paperclip.example/app/PAP/dashboard",
);
});
});
describe("renderCompanyImportPreview", () => {
it("summarizes the preview with counts, selection info, and truncated examples", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
plan: {
companyAction: "update",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T17:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
},
files: {
"COMPANY.md": "# Source Co",
},
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
warnings: ["One warning"],
errors: ["One error"],
};
const rendered = renderCompanyImportPreview(preview, {
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
targetLabel: "Imported Co (company-123)",
infoMessages: ["Using claude-local adapter"],
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
expect(rendered).toContain("skills: 1 skill packaged");
expect(rendered).toContain("+1 more");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Warnings");
expect(rendered).toContain("Errors");
});
});
describe("renderCompanyImportResult", () => {
it("summarizes import results with created, updated, and skipped counts", () => {
const rendered = renderCompanyImportResult(
{
company: {
id: "company-123",
name: "Imported Co",
action: "updated",
},
agents: [
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
envInputs: [],
warnings: ["Review API keys"],
},
{
targetLabel: "Imported Co (company-123)",
companyUrl: "https://paperclip.example/PAP/dashboard",
infoMessages: ["Using claude-local adapter"],
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
});
});
describe("import selection catalog", () => {
it("defaults to everything and keeps project selection separate from task selection", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [],
},
files: {
"COMPANY.md": "# Source Co",
"README.md": "# Readme",
".paperclip.yaml": "schema: paperclip/v1\n",
"images/company-logo.png": {
encoding: "base64",
data: "",
contentType: "image/png",
},
"projects/alpha/PROJECT.md": "# Alpha",
"projects/alpha/notes.md": "project notes",
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
"projects/alpha/issues/kickoff/details.md": "task details",
"agents/ceo/AGENT.md": "# CEO",
"agents/ceo/prompt.md": "prompt",
"skills/skill-a/SKILL.md": "# Skill A",
"skills/skill-a/helper.md": "helper",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
expect(state.company).toBe(true);
expect(state.projects.has("alpha")).toBe(true);
expect(state.issues.has("kickoff")).toBe(true);
expect(state.agents.has("ceo")).toBe(true);
expect(state.skills.has("skill-a")).toBe(true);
state.company = false;
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
expect(selectedFiles).toContain("projects/alpha/notes.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
});
describe("default adapter overrides", () => {
it("maps process-only imported agents to claude_local", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
plan: {
companyAction: "none",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:20:00.000Z",
source: null,
includes: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
company: null,
sidebar: null,
agents: [
{
slug: "legacy-agent",
name: "Legacy Agent",
path: "agents/legacy-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
{
slug: "explicit-agent",
name: "Explicit Agent",
path: "agents/explicit-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
};
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
"legacy-agent": {
adapterType: "claude_local",
},
});
});
});

View File

@@ -46,6 +46,9 @@ function createTempConfig(): string {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: {

View File

@@ -0,0 +1,177 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, readFile } from "node:fs/promises";
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import type { FeedbackTrace } from "@paperclipai/shared";
import { readZipArchive } from "../commands/client/zip.js";
import {
buildFeedbackTraceQuery,
registerFeedbackCommands,
renderFeedbackReport,
summarizeFeedbackTraces,
writeFeedbackExportBundle,
} from "../commands/client/feedback.js";
function makeTrace(overrides: Partial<FeedbackTrace> = {}): FeedbackTrace {
return {
id: "trace-12345678",
companyId: "company-123",
feedbackVoteId: "vote-12345678",
issueId: "issue-123",
projectId: "project-123",
issueIdentifier: "PAP-123",
issueTitle: "Fix the feedback command",
authorUserId: "user-123",
targetType: "issue_comment",
targetId: "comment-123",
vote: "down",
status: "pending",
destination: "paperclip_labs_feedback_v1",
exportId: null,
consentVersion: "feedback-data-sharing-v1",
schemaVersion: "1",
bundleVersion: "1",
payloadVersion: "1",
payloadDigest: null,
payloadSnapshot: {
vote: {
value: "down",
reason: "Needed more detail",
},
},
targetSummary: {
label: "Comment",
excerpt: "The first answer was too vague.",
authorAgentId: "agent-123",
authorUserId: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
documentKey: null,
documentTitle: null,
revisionNumber: null,
},
redactionSummary: null,
attemptCount: 0,
lastAttemptedAt: null,
exportedAt: null,
failureReason: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
updatedAt: new Date("2026-03-31T12:02:00.000Z"),
...overrides,
};
}
describe("registerFeedbackCommands", () => {
it("registers the top-level feedback commands", () => {
const program = new Command();
expect(() => registerFeedbackCommands(program)).not.toThrow();
const feedback = program.commands.find((command) => command.name() === "feedback");
expect(feedback).toBeDefined();
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]);
expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
});
describe("buildFeedbackTraceQuery", () => {
it("encodes all supported filters", () => {
expect(
buildFeedbackTraceQuery({
targetType: "issue_comment",
vote: "down",
status: "pending",
projectId: "project-123",
issueId: "issue-123",
from: "2026-03-31T00:00:00.000Z",
to: "2026-03-31T23:59:59.999Z",
sharedOnly: true,
}),
).toBe(
"?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true",
);
});
});
describe("renderFeedbackReport", () => {
it("includes summary counts and the optional reason", () => {
const traces = [
makeTrace(),
makeTrace({
id: "trace-87654321",
feedbackVoteId: "vote-87654321",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const report = renderFeedbackReport({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
summary: summarizeFeedbackTraces(traces),
includePayloads: false,
});
expect(report).toContain("Paperclip Feedback Report");
expect(report).toContain("thumbs up");
expect(report).toContain("thumbs down");
expect(report).toContain("Needed more detail");
});
});
describe("writeFeedbackExportBundle", () => {
it("writes votes, traces, a manifest, and a zip archive", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-"));
const outputDir = path.join(tempDir, "feedback-export");
const traces = [
makeTrace(),
makeTrace({
id: "trace-abcdef12",
feedbackVoteId: "vote-abcdef12",
issueIdentifier: "PAP-124",
issueId: "issue-124",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const exported = await writeFeedbackExportBundle({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
outputDir,
});
expect(exported.manifest.summary.total).toBe(2);
expect(exported.manifest.summary.withReason).toBe(1);
const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as {
files: { votes: string[]; traces: string[]; zip: string };
};
expect(manifest.files.votes).toHaveLength(2);
expect(manifest.files.traces).toHaveLength(2);
const archive = await readFile(exported.zipPath);
const zip = await readZipArchive(archive);
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining([
"index.json",
`votes/${manifest.files.votes[0]}`,
`traces/${manifest.files.traces[0]}`,
]),
);
});
});

View File

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

View File

@@ -0,0 +1,87 @@
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
@@ -58,4 +58,49 @@ describe("PaperclipApiClient", () => {
details: { issueId: "1" },
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
recoverAuth,
});
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
expect(result).toEqual({ ok: true });
expect(recoverAuth).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
});
});

View File

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

View File

@@ -0,0 +1,249 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
agents,
companies,
createDb,
projects,
routines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { disableAllRoutinesInConfig } from "../commands/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres routines CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) {
const config = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "doctor" as const,
},
database: {
mode: "postgres" as const,
connectionString,
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
embeddedPostgresPort: 54329,
backup: {
enabled: false,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(tempRoot, "backups"),
},
},
logging: {
mode: "file" as const,
logDir: path.join(tempRoot, "logs"),
},
server: {
deploymentMode: "local_trusted" as const,
exposure: "private" as const,
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: false,
},
auth: {
baseUrlMode: "auto" as const,
disableSignUp: false,
},
storage: {
provider: "local_disk" as const,
localDisk: {
baseDir: path.join(tempRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted" as const,
strictMode: false,
localEncrypted: {
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
},
},
};
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
describeEmbeddedPostgres("disableAllRoutinesInConfig", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let tempRoot = "";
let configPath = "";
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-cli-db-");
db = createDb(tempDb.connectionString);
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-cli-config-"));
configPath = path.join(tempRoot, "config.json");
writeTestConfig(configPath, tempRoot, tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routines);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("pauses only non-archived routines for the selected company", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const activeRoutineId = randomUUID();
const pausedRoutineId = randomUUID();
const archivedRoutineId = randomUUID();
const otherCompanyRoutineId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other company",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "Coder",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "Other coder",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(projects).values([
{
id: projectId,
companyId,
name: "Project",
status: "in_progress",
},
{
id: otherProjectId,
companyId: otherCompanyId,
name: "Other project",
status: "in_progress",
},
]);
await db.insert(routines).values([
{
id: activeRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Active routine",
status: "active",
},
{
id: pausedRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Paused routine",
status: "paused",
},
{
id: archivedRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Archived routine",
status: "archived",
},
{
id: otherCompanyRoutineId,
companyId: otherCompanyId,
projectId: otherProjectId,
assigneeAgentId: otherAgentId,
title: "Other company routine",
status: "active",
},
]);
const result = await disableAllRoutinesInConfig({
config: configPath,
companyId,
});
expect(result).toMatchObject({
companyId,
totalRoutines: 3,
pausedCount: 1,
alreadyPausedCount: 1,
archivedCount: 1,
});
const companyRoutines = await db
.select({
id: routines.id,
status: routines.status,
})
.from(routines)
.where(eq(routines.companyId, companyId));
const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status]));
expect(statusById.get(activeRoutineId)).toBe("paused");
expect(statusById.get(pausedRoutineId)).toBe("paused");
expect(statusById.get(archivedRoutineId)).toBe("archived");
const otherCompanyRoutine = await db
.select({
status: routines.status,
})
.from(routines)
.where(eq(routines.id, otherCompanyRoutineId));
expect(otherCompanyRoutine[0]?.status).toBe("active");
});
});

View File

@@ -0,0 +1,117 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
function makeConfigPath(root: string, enabled: boolean): string {
const configPath = path.join(root, ".paperclip", "config.json");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
$meta: {
version: 1,
updatedAt: "2026-03-31T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(root, "runtime", "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(root, "runtime", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(root, "runtime", "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(root, "runtime", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(root, "runtime", "secrets", "master.key"),
},
},
}, null, 2));
return configPath;
}
describe("cli telemetry", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
for (const key of CI_ENV_VARS) {
delete process.env[key];
}
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.resetModules();
});
it("respects telemetry.enabled=false from the config file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
const configPath = makeConfigPath(root, false);
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
const { initTelemetryFromConfigFile } = await import("../telemetry.js");
const client = initTelemetryFromConfigFile(configPath);
expect(client).toBeNull();
expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false);
});
it("creates telemetry state only after the first event is tracked", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
const { initTelemetry, flushTelemetry } = await import("../telemetry.js");
const client = initTelemetry({ enabled: true });
const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json");
expect(client).not.toBeNull();
expect(fs.existsSync(statePath)).toBe(false);
client!.track("install.started", { setupMode: "quickstart" });
expect(fs.existsSync(statePath)).toBe(true);
await flushTelemetry();
});
});

View File

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

View File

@@ -0,0 +1,282 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import { buildCliCommandLabel } from "./command-label.js";
import { resolveDefaultCliAuthPath } from "../config/home.js";
type RequestedAccess = "board" | "instance_admin_required";
interface BoardAuthCredential {
apiBase: string;
token: string;
createdAt: string;
updatedAt: string;
userId?: string | null;
}
interface BoardAuthStore {
version: 1;
credentials: Record<string, BoardAuthCredential>;
}
interface CreateChallengeResponse {
id: string;
token: string;
boardApiToken: string;
approvalPath: string;
approvalUrl: string | null;
pollPath: string;
expiresAt: string;
suggestedPollIntervalMs: number;
}
interface ChallengeStatusResponse {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: RequestedAccess;
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
}
function defaultBoardAuthStore(): BoardAuthStore {
return {
version: 1,
credentials: {},
};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function normalizeApiBase(apiBase: string): string {
return apiBase.trim().replace(/\/+$/, "");
}
export function resolveBoardAuthStorePath(overridePath?: string): string {
if (overridePath?.trim()) return path.resolve(overridePath.trim());
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
return resolveDefaultCliAuthPath();
}
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
const filePath = resolveBoardAuthStorePath(storePath);
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
const normalized: Record<string, BoardAuthCredential> = {};
for (const [key, value] of Object.entries(credentials)) {
if (typeof value !== "object" || value === null) continue;
const record = value as unknown as Record<string, unknown>;
const apiBase = toStringOrNull(record.apiBase);
const token = toStringOrNull(record.token);
const createdAt = toStringOrNull(record.createdAt);
const updatedAt = toStringOrNull(record.updatedAt);
if (!apiBase || !token || !createdAt || !updatedAt) continue;
normalized[normalizeApiBase(key)] = {
apiBase,
token,
createdAt,
updatedAt,
userId: toStringOrNull(record.userId),
};
}
return {
version: 1,
credentials: normalized,
};
}
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
const filePath = resolveBoardAuthStorePath(storePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
}
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
const store = readBoardAuthStore(storePath);
return store.credentials[normalizeApiBase(apiBase)] ?? null;
}
export function setStoredBoardCredential(input: {
apiBase: string;
token: string;
userId?: string | null;
storePath?: string;
}): BoardAuthCredential {
const normalizedApiBase = normalizeApiBase(input.apiBase);
const store = readBoardAuthStore(input.storePath);
const now = new Date().toISOString();
const existing = store.credentials[normalizedApiBase];
const credential: BoardAuthCredential = {
apiBase: normalizedApiBase,
token: input.token.trim(),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
userId: input.userId ?? existing?.userId ?? null,
};
store.credentials[normalizedApiBase] = credential;
writeBoardAuthStore(store, input.storePath);
return credential;
}
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
const normalizedApiBase = normalizeApiBase(apiBase);
const store = readBoardAuthStore(storePath);
if (!store.credentials[normalizedApiBase]) return false;
delete store.credentials[normalizedApiBase];
writeBoardAuthStore(store, storePath);
return true;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? undefined);
if (init?.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
const response = await fetch(url, {
...init,
headers,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
const message =
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `Request failed: ${response.status}`;
throw new Error(message);
}
return response.json() as Promise<T>;
}
export function openUrl(url: string): boolean {
const platform = process.platform;
try {
if (platform === "darwin") {
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
child.unref();
return true;
}
if (platform === "win32") {
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
child.unref();
return true;
}
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
child.unref();
return true;
} catch {
return false;
}
}
export async function loginBoardCli(params: {
apiBase: string;
requestedAccess: RequestedAccess;
requestedCompanyId?: string | null;
clientName?: string | null;
command?: string;
storePath?: string;
print?: boolean;
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
const apiBase = normalizeApiBase(params.apiBase);
const createUrl = `${apiBase}/api/cli-auth/challenges`;
const command = params.command?.trim() || buildCliCommandLabel();
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
method: "POST",
body: JSON.stringify({
command,
clientName: params.clientName?.trim() || "paperclipai cli",
requestedAccess: params.requestedAccess,
requestedCompanyId: params.requestedCompanyId?.trim() || null,
}),
});
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
if (params.print !== false) {
console.error(pc.bold("Board authentication required"));
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
}
const opened = openUrl(approvalUrl);
if (params.print !== false && opened) {
console.error(pc.dim("Opened the approval page in your browser."));
}
const expiresAtMs = Date.parse(challenge.expiresAt);
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
const status = await requestJson<ChallengeStatusResponse>(
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
);
if (status.status === "approved") {
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
`${apiBase}/api/cli-auth/me`,
{
headers: {
authorization: `Bearer ${challenge.boardApiToken}`,
},
},
);
setStoredBoardCredential({
apiBase,
token: challenge.boardApiToken,
userId: me.userId ?? me.user?.id ?? null,
storePath: params.storePath,
});
return {
token: challenge.boardApiToken,
approvalUrl,
userId: me.userId ?? me.user?.id ?? null,
};
}
if (status.status === "cancelled") {
throw new Error("CLI auth challenge was cancelled.");
}
if (status.status === "expired") {
throw new Error("CLI auth challenge expired before approval.");
}
await sleep(pollMs);
}
throw new Error("CLI auth challenge expired before approval.");
}
export async function revokeStoredBoardCredential(params: {
apiBase: string;
token: string;
}): Promise<void> {
const apiBase = normalizeApiBase(params.apiBase);
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
method: "POST",
headers: {
authorization: `Bearer ${params.token}`,
},
body: JSON.stringify({}),
});
}

View File

@@ -0,0 +1,4 @@
export function buildCliCommandLabel(): string {
const args = process.argv.slice(2);
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
}

View File

@@ -13,25 +13,54 @@ export class ApiRequestError extends Error {
}
}
export class ApiConnectionError extends Error {
url: string;
method: string;
causeMessage?: string;
constructor(input: {
apiBase: string;
path: string;
method: string;
cause?: unknown;
}) {
const url = buildUrl(input.apiBase, input.path);
const causeMessage = formatConnectionCause(input.cause);
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
this.url = url;
this.method = input.method;
this.causeMessage = causeMessage;
}
}
interface RequestOptions {
ignoreNotFound?: boolean;
}
interface RecoverAuthInput {
path: string;
method: string;
error: ApiRequestError;
}
interface ApiClientOptions {
apiBase: string;
apiKey?: string;
runId?: string;
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
}
export class PaperclipApiClient {
readonly apiBase: string;
readonly apiKey?: string;
apiKey?: string;
readonly runId?: string;
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
constructor(opts: ApiClientOptions) {
this.apiBase = opts.apiBase.replace(/\/+$/, "");
this.apiKey = opts.apiKey?.trim() || undefined;
this.runId = opts.runId?.trim() || undefined;
this.recoverAuth = opts.recoverAuth;
}
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
@@ -56,8 +85,18 @@ export class PaperclipApiClient {
return this.request<T>(path, { method: "DELETE" }, opts);
}
private async request<T>(path: string, init: RequestInit, opts?: RequestOptions): Promise<T | null> {
setApiKey(apiKey: string | undefined) {
this.apiKey = apiKey?.trim() || undefined;
}
private async request<T>(
path: string,
init: RequestInit,
opts?: RequestOptions,
hasRetriedAuth = false,
): Promise<T | null> {
const url = buildUrl(this.apiBase, path);
const method = String(init.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
accept: "application/json",
@@ -76,17 +115,39 @@ export class PaperclipApiClient {
headers["x-paperclip-run-id"] = this.runId;
}
const response = await fetch(url, {
...init,
headers,
});
let response: Response;
try {
response = await fetch(url, {
...init,
headers,
});
} catch (error) {
throw new ApiConnectionError({
apiBase: this.apiBase,
path,
method,
cause: error,
});
}
if (opts?.ignoreNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
throw await toApiError(response);
const apiError = await toApiError(response);
if (!hasRetriedAuth && this.recoverAuth) {
const recoveredToken = await this.recoverAuth({
path,
method,
error: apiError,
});
if (recoveredToken) {
this.setApiKey(recoveredToken);
return this.request<T>(path, init, opts, true);
}
}
throw apiError;
}
if (response.status === 204) {
@@ -136,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
}
function buildConnectionErrorMessage(input: {
apiBase: string;
url: string;
method: string;
causeMessage?: string;
}): string {
const healthUrl = buildHealthCheckUrl(input.url);
const lines = [
"Could not reach the Paperclip API.",
"",
`Request: ${input.method} ${input.url}`,
];
if (input.causeMessage) {
lines.push(`Cause: ${input.causeMessage}`);
}
lines.push(
"",
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
);
return lines.join("\n");
}
function buildHealthCheckUrl(requestUrl: string): string {
const url = new URL(requestUrl);
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
url.search = "";
url.hash = "";
return url.toString();
}
function formatConnectionCause(error: unknown): string | undefined {
if (!error) return undefined;
if (error instanceof Error) {
return error.message.trim() || error.name;
}
const message = String(error).trim();
return message || undefined;
}
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (Array.isArray(headers)) {

View File

@@ -0,0 +1,113 @@
import type { Command } from "commander";
import {
getStoredBoardCredential,
loginBoardCli,
removeStoredBoardCredential,
revokeStoredBoardCredential,
} from "../../client/board-auth.js";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AuthLoginOptions extends BaseClientOptions {
instanceAdmin?: boolean;
}
interface AuthLogoutOptions extends BaseClientOptions {}
interface AuthWhoamiOptions extends BaseClientOptions {}
export function registerClientAuthCommands(auth: Command): void {
addCommonClientOptions(
auth
.command("login")
.description("Authenticate the CLI for board-user access")
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
.action(async (opts: AuthLoginOptions) => {
try {
const ctx = resolveCommandContext(opts);
const login = await loginBoardCli({
apiBase: ctx.api.apiBase,
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
requestedCompanyId: ctx.companyId ?? null,
command: "paperclipai auth login",
});
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
userId: login.userId ?? null,
approvalUrl: login.approvalUrl,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
auth
.command("logout")
.description("Remove the stored board-user credential for this API base")
.action(async (opts: AuthLogoutOptions) => {
try {
const ctx = resolveCommandContext(opts);
const credential = getStoredBoardCredential(ctx.api.apiBase);
if (!credential) {
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
return;
}
let revoked = false;
try {
await revokeStoredBoardCredential({
apiBase: ctx.api.apiBase,
token: credential.token,
});
revoked = true;
} catch {
// Remove the local credential even if the server-side revoke fails.
}
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
revoked,
removedLocalCredential,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
auth
.command("whoami")
.description("Show the current board-user identity for this API base")
.action(async (opts: AuthWhoamiOptions) => {
try {
const ctx = resolveCommandContext(opts);
const me = await ctx.api.get<{
user: { id: string; name: string; email: string } | null;
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
source: string;
keyId: string | null;
}>("/api/cli-auth/me");
printOutput(me, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}

View File

@@ -1,5 +1,7 @@
import pc from "picocolors";
import type { Command } from "commander";
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
import { buildCliCommandLabel } from "../../client/command-label.js";
import { readConfig } from "../../config/store.js";
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
@@ -53,10 +55,12 @@ export function resolveCommandContext(
profile.apiBase ||
inferApiBaseFromConfig(options.config);
const apiKey =
const explicitApiKey =
options.apiKey?.trim() ||
process.env.PAPERCLIP_API_KEY?.trim() ||
readKeyFromProfileEnv(profile);
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
const apiKey = explicitApiKey || storedBoardCredential?.token;
const companyId =
options.companyId?.trim() ||
@@ -69,7 +73,27 @@ export function resolveCommandContext(
);
}
const api = new PaperclipApiClient({ apiBase, apiKey });
const api = new PaperclipApiClient({
apiBase,
apiKey,
recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth()
? undefined
: async ({ error }) => {
const requestedAccess = error.message.includes("Instance admin required")
? "instance_admin_required"
: "board";
if (!shouldRecoverBoardAuth(error)) {
return null;
}
const login = await loginBoardCli({
apiBase,
requestedAccess,
requestedCompanyId: companyId ?? null,
command: buildCliCommandLabel(),
});
return login.token;
},
});
return {
api,
companyId,
@@ -79,6 +103,16 @@ export function resolveCommandContext(
};
}
function shouldRecoverBoardAuth(error: ApiRequestError): boolean {
if (error.status === 401) return true;
if (error.status !== 403) return false;
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
}
function canAttemptInteractiveBoardAuth(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
if (opts.json) {
console.log(JSON.stringify(data, null, 2));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,645 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import pc from "picocolors";
import { Command } from "commander";
import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
interface FeedbackFilterOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
export interface FeedbackTraceQueryOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
interface FeedbackReportOptions extends FeedbackFilterOptions {
payloads?: boolean;
}
interface FeedbackExportOptions extends FeedbackFilterOptions {
out?: string;
}
interface FeedbackSummary {
total: number;
thumbsUp: number;
thumbsDown: number;
withReason: number;
statuses: Record<string, number>;
}
interface FeedbackExportManifest {
exportedAt: string;
serverUrl: string;
companyId: string;
summary: FeedbackSummary & {
uniqueIssues: number;
issues: string[];
};
files: {
votes: string[];
traces: string[];
fullTraces: string[];
zip: string;
};
}
interface FeedbackExportResult {
outputDir: string;
zipPath: string;
manifest: FeedbackExportManifest;
}
export function registerFeedbackCommands(program: Command): void {
const feedback = program.command("feedback").description("Inspect and export local feedback traces");
addCommonClientOptions(
feedback
.command("report")
.description("Render a terminal report for company feedback traces")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--payloads", "Include raw payload dumps in the terminal report", false)
.action(async (opts: FeedbackReportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const summary = summarizeFeedbackTraces(traces);
if (ctx.json) {
printOutput(
{
apiBase: ctx.api.apiBase,
companyId,
summary,
traces,
},
{ json: true },
);
return;
}
console.log(renderFeedbackReport({
apiBase: ctx.api.apiBase,
companyId,
traces,
summary,
includePayloads: Boolean(opts.payloads),
}));
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
feedback
.command("export")
.description("Export feedback votes and raw trace bundles into a folder plus zip archive")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--out <path>", "Output directory (default: ./feedback-export-<timestamp>)")
.action(async (opts: FeedbackExportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName());
const exported = await writeFeedbackExportBundle({
apiBase: ctx.api.apiBase,
companyId,
traces,
outputDir,
traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id),
});
if (ctx.json) {
printOutput(
{
companyId,
outputDir: exported.outputDir,
zipPath: exported.zipPath,
summary: exported.manifest.summary,
},
{ json: true },
);
return;
}
console.log(renderFeedbackExportSummary(exported));
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
export async function resolveFeedbackCompanyId(
ctx: ResolvedClientContext,
explicitCompanyId?: string,
): Promise<string> {
const direct = explicitCompanyId?.trim() || ctx.companyId?.trim();
if (direct) return direct;
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
const companyId = companies[0]?.id?.trim();
if (!companyId) {
throw new Error(
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a CLI context default.",
);
}
return companyId;
}
export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string {
const params = new URLSearchParams();
if (opts.targetType) params.set("targetType", opts.targetType);
if (opts.vote) params.set("vote", opts.vote);
if (opts.status) params.set("status", opts.status);
if (opts.projectId) params.set("projectId", opts.projectId);
if (opts.issueId) params.set("issueId", opts.issueId);
if (opts.from) params.set("from", opts.from);
if (opts.to) params.set("to", opts.to);
if (opts.sharedOnly) params.set("sharedOnly", "true");
if (includePayload) params.set("includePayload", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" {
if (!value || value === "ndjson") return "ndjson";
if (value === "json") return "json";
throw new Error(`Unsupported export format: ${value}`);
}
export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
if (normalizeFeedbackTraceExportFormat(format) === "json") {
return JSON.stringify(traces, null, 2);
}
return traces.map((trace) => JSON.stringify(trace)).join("\n");
}
export async function fetchCompanyFeedbackTraces(
ctx: ResolvedClientContext,
companyId: string,
opts: FeedbackFilterOptions,
): Promise<FeedbackTrace[]> {
return (
(await ctx.api.get<FeedbackTrace[]>(
`/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`,
)) ?? []
);
}
export async function fetchFeedbackTraceBundle(
ctx: ResolvedClientContext,
traceId: string,
): Promise<FeedbackTraceBundle> {
const bundle = await ctx.api.get<FeedbackTraceBundle>(`/api/feedback-traces/${traceId}/bundle`);
if (!bundle) {
throw new Error(`Feedback trace bundle ${traceId} not found`);
}
return bundle;
}
export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary {
const statuses: Record<string, number> = {};
let thumbsUp = 0;
let thumbsDown = 0;
let withReason = 0;
for (const trace of traces) {
if (trace.vote === "up") thumbsUp += 1;
if (trace.vote === "down") thumbsDown += 1;
if (readFeedbackReason(trace)) withReason += 1;
statuses[trace.status] = (statuses[trace.status] ?? 0) + 1;
}
return {
total: traces.length,
thumbsUp,
thumbsDown,
withReason,
statuses,
};
}
export function renderFeedbackReport(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
summary: FeedbackSummary;
includePayloads: boolean;
}): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Report")));
lines.push(pc.dim(new Date().toISOString()));
lines.push(horizontalRule());
lines.push(`${pc.dim("Server:")} ${input.apiBase}`);
lines.push(`${pc.dim("Company:")} ${input.companyId}`);
lines.push("");
if (input.traces.length === 0) {
lines.push(pc.yellow("[!!] No feedback traces found."));
lines.push("");
return lines.join("\n");
}
lines.push(pc.bold(pc.cyan("Summary")));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`);
lines.push(` ${pc.bold(String(input.summary.total))} total traces`);
lines.push("");
lines.push(pc.dim("Export status:"));
for (const status of ["pending", "sent", "local_only", "failed"]) {
lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`);
}
lines.push("");
lines.push(pc.bold(pc.cyan("Trace Details")));
lines.push(horizontalRule());
for (const trace of input.traces) {
const voteColor = trace.vote === "up" ? pc.green : pc.red;
const voteIcon = trace.vote === "up" ? "^" : "v";
const issueRef = trace.issueIdentifier ?? trace.issueId;
const label = trace.targetSummary.label?.trim() || trace.targetType;
const excerpt = compactText(trace.targetSummary.excerpt);
const reason = readFeedbackReason(trace);
lines.push(
` ${voteColor(voteIcon)} ${pc.bold(issueRef)} ${pc.dim(compactText(trace.issueTitle, 64))}`,
);
lines.push(
` ${pc.dim("Trace:")} ${trace.id.slice(0, 8)} ${pc.dim("Status:")} ${trace.status} ${pc.dim("Date:")} ${formatTimestamp(trace.createdAt)}`,
);
lines.push(` ${pc.dim("Target:")} ${label}`);
if (excerpt) {
lines.push(` ${pc.dim("Excerpt:")} ${excerpt}`);
}
if (reason) {
lines.push(` ${pc.yellow(pc.bold("Reason:"))} ${pc.yellow(reason)}`);
}
lines.push("");
}
if (input.includePayloads) {
lines.push(pc.bold(pc.cyan("Raw Payloads")));
lines.push(horizontalRule());
for (const trace of input.traces) {
if (!trace.payloadSnapshot) continue;
const issueRef = trace.issueIdentifier ?? trace.issueId;
lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`);
const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? [];
for (const line of body) {
lines.push(` ${pc.dim(line)}`);
}
lines.push("");
}
}
lines.push(horizontalRule());
lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`));
lines.push("");
return lines.join("\n");
}
export async function writeFeedbackExportBundle(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
outputDir: string;
traceBundleFetcher?: (trace: FeedbackTrace) => Promise<FeedbackTraceBundle>;
}): Promise<FeedbackExportResult> {
await ensureEmptyOutputDirectory(input.outputDir);
await mkdir(path.join(input.outputDir, "votes"), { recursive: true });
await mkdir(path.join(input.outputDir, "traces"), { recursive: true });
await mkdir(path.join(input.outputDir, "full-traces"), { recursive: true });
const summary = summarizeFeedbackTraces(input.traces);
const voteFiles: string[] = [];
const traceFiles: string[] = [];
const fullTraceDirs: string[] = [];
const fullTraceFiles: string[] = [];
const issueSet = new Set<string>();
for (const trace of input.traces) {
const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId);
const voteRecord = buildFeedbackVoteRecord(trace);
const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`;
const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`;
voteFiles.push(voteFileName);
traceFiles.push(traceFileName);
issueSet.add(trace.issueIdentifier ?? trace.issueId);
await writeFile(
path.join(input.outputDir, "votes", voteFileName),
`${JSON.stringify(voteRecord, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(input.outputDir, "traces", traceFileName),
`${JSON.stringify(trace, null, 2)}\n`,
"utf8",
);
if (input.traceBundleFetcher) {
const bundle = await input.traceBundleFetcher(trace);
const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`;
const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName);
await mkdir(bundleDir, { recursive: true });
fullTraceDirs.push(bundleDirName);
await writeFile(
path.join(bundleDir, "bundle.json"),
`${JSON.stringify(bundle, null, 2)}\n`,
"utf8",
);
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json"));
for (const file of bundle.files) {
const targetPath = path.join(bundleDir, file.path);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, file.contents, "utf8");
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/")));
}
}
}
const zipPath = `${input.outputDir}.zip`;
const manifest: FeedbackExportManifest = {
exportedAt: new Date().toISOString(),
serverUrl: input.apiBase,
companyId: input.companyId,
summary: {
...summary,
uniqueIssues: issueSet.size,
issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)),
},
files: {
votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)),
traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)),
fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)),
zip: path.basename(zipPath),
},
};
await writeFile(
path.join(input.outputDir, "index.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const archiveFiles = await collectJsonFilesForArchive(input.outputDir, [
"index.json",
...manifest.files.votes.map((file) => path.posix.join("votes", file)),
...manifest.files.traces.map((file) => path.posix.join("traces", file)),
...fullTraceFiles,
]);
await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir)));
return {
outputDir: input.outputDir,
zipPath,
manifest,
};
}
export function renderFeedbackExportSummary(exported: FeedbackExportResult): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Export")));
lines.push(pc.dim(exported.manifest.exportedAt));
lines.push(horizontalRule());
lines.push(`${pc.dim("Company:")} ${exported.manifest.companyId}`);
lines.push(`${pc.dim("Output:")} ${exported.outputDir}`);
lines.push(`${pc.dim("Archive:")} ${exported.zipPath}`);
lines.push("");
lines.push(pc.bold("Export Summary"));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`);
lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`);
lines.push("");
lines.push(pc.dim("Files:"));
lines.push(` ${path.join(exported.outputDir, "index.json")}`);
lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`);
lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`);
lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`);
lines.push(` ${exported.zipPath}`);
lines.push("");
return lines.join("\n");
}
function readFeedbackReason(trace: FeedbackTrace): string | null {
const payload = asRecord(trace.payloadSnapshot);
const vote = asRecord(payload?.vote);
const reason = vote?.reason;
return typeof reason === "string" && reason.trim() ? reason.trim() : null;
}
function buildFeedbackVoteRecord(trace: FeedbackTrace) {
return {
voteId: trace.feedbackVoteId,
traceId: trace.id,
issueId: trace.issueId,
issueIdentifier: trace.issueIdentifier,
issueTitle: trace.issueTitle,
vote: trace.vote,
targetType: trace.targetType,
targetId: trace.targetId,
targetSummary: trace.targetSummary,
status: trace.status,
consentVersion: trace.consentVersion,
createdAt: trace.createdAt,
updatedAt: trace.updatedAt,
reason: readFeedbackReason(trace),
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function compactText(value: string | null | undefined, maxLength = 88): string | null {
if (!value) return null;
const compact = value.replace(/\s+/g, " ").trim();
if (!compact) return null;
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, maxLength - 3)}...`;
}
function formatTimestamp(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " ");
if (typeof value === "string") return value.slice(0, 19).replace("T", " ");
return "-";
}
function horizontalRule(): string {
return pc.dim("-".repeat(72));
}
function padRight(value: string, width: number): string {
return `${value}${" ".repeat(Math.max(0, width - value.length))}`;
}
function defaultFeedbackExportDirName(): string {
const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return `feedback-export-${iso}`;
}
async function ensureEmptyOutputDirectory(outputDir: string): Promise<void> {
try {
const info = await stat(outputDir);
if (!info.isDirectory()) {
throw new Error(`Output path already exists and is not a directory: ${outputDir}`);
}
const entries = await readdir(outputDir);
if (entries.length > 0) {
throw new Error(`Output directory already exists and is not empty: ${outputDir}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (/ENOENT/.test(message)) {
await mkdir(outputDir, { recursive: true });
return;
}
throw error;
}
}
async function collectJsonFilesForArchive(
outputDir: string,
relativePaths: string[],
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
for (const relativePath of relativePaths) {
const normalized = relativePath.replace(/\\/g, "/");
files[normalized] = await readFile(path.join(outputDir, normalized), "utf8");
}
return files;
}
function sanitizeFileSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback";
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createStoredZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}

View File

@@ -1,8 +1,10 @@
import { Command } from "commander";
import { writeFile } from "node:fs/promises";
import {
addIssueCommentSchema,
checkoutIssueSchema,
createIssueSchema,
type FeedbackTrace,
updateIssueSchema,
type Issue,
type IssueComment,
@@ -15,6 +17,11 @@ import {
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
import {
buildFeedbackTraceQuery,
normalizeFeedbackTraceExportFormat,
serializeFeedbackTraces,
} from "./feedback.js";
interface IssueBaseOptions extends BaseClientOptions {
status?: string;
@@ -61,6 +68,18 @@ interface IssueCheckoutOptions extends BaseClientOptions {
expectedStatuses?: string;
}
interface IssueFeedbackOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
includePayload?: boolean;
out?: string;
format?: string;
}
export function registerIssueCommands(program: Command): void {
const issue = program.command("issue").description("Issue operations");
@@ -237,6 +256,85 @@ export function registerIssueCommands(program: Command): void {
}),
);
addCommonClientOptions(
issue
.command("feedback:list")
.description("List feedback traces for an issue")
.argument("<issueId>", "Issue ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the response")
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts);
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
)) ?? [];
if (ctx.json) {
printOutput(traces, { json: true });
return;
}
printOutput(
traces.map((trace) => ({
id: trace.id,
issue: trace.issueIdentifier ?? trace.issueId,
vote: trace.vote,
status: trace.status,
targetType: trace.targetType,
target: trace.targetSummary.label,
})),
{ json: false },
);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("feedback:export")
.description("Export feedback traces for an issue")
.argument("<issueId>", "Issue ID")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--include-payload", "Include stored payload snapshots in the export")
.option("--out <path>", "Write export to a file path instead of stdout")
.option("--format <format>", "Export format: json or ndjson", "ndjson")
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
try {
const ctx = resolveCommandContext(opts);
const traces = (await ctx.api.get<FeedbackTrace[]>(
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
)) ?? [];
const serialized = serializeFeedbackTraces(traces, opts.format);
if (opts.out?.trim()) {
await writeFile(opts.out, serialized, "utf8");
if (ctx.json) {
printOutput(
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
{ json: true },
);
return;
}
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
return;
}
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("checkout")

View File

@@ -0,0 +1,129 @@
import { inflateRawSync } from "node:zlib";
import path from "node:path";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const textDecoder = new TextDecoder();
export const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function normalizeArchivePath(pathValue: string) {
return pathValue
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.join("/");
}
function readUint16(source: Uint8Array, offset: number) {
return source[offset]! | (source[offset + 1]! << 8);
}
function readUint32(source: Uint8Array, offset: number) {
return (
source[offset]! |
(source[offset + 1]! << 8) |
(source[offset + 2]! << 16) |
(source[offset + 3]! << 24)
) >>> 0;
}
function sharedArchiveRoot(paths: string[]) {
if (paths.length === 0) return null;
const firstSegments = paths
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
.filter((parts) => parts.length > 0);
if (firstSegments.length === 0) return null;
const candidate = firstSegments[0]![0]!;
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
? candidate
: null;
}
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
if (!contentType) return textDecoder.decode(bytes);
return {
encoding: "base64",
data: Buffer.from(bytes).toString("base64"),
contentType,
};
}
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
if (compressionMethod === 0) return bytes;
if (compressionMethod !== 8) {
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
}
return new Uint8Array(inflateRawSync(bytes));
}
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
rootPath: string | null;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0;
while (offset + 4 <= bytes.length) {
const signature = readUint32(bytes, offset);
if (signature === 0x02014b50 || signature === 0x06054b50) break;
if (signature !== 0x04034b50) {
throw new Error("Invalid zip archive: unsupported local file header.");
}
if (offset + 30 > bytes.length) {
throw new Error("Invalid zip archive: truncated local file header.");
}
const generalPurposeFlag = readUint16(bytes, offset + 6);
const compressionMethod = readUint16(bytes, offset + 8);
const compressedSize = readUint32(bytes, offset + 18);
const fileNameLength = readUint16(bytes, offset + 26);
const extraFieldLength = readUint16(bytes, offset + 28);
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
const bodyEnd = bodyOffset + compressedSize;
if (bodyEnd > bytes.length) {
throw new Error("Invalid zip archive: truncated file contents.");
}
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
const archivePath = normalizeArchivePath(rawArchivePath);
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
if (archivePath && !isDirectoryEntry) {
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
entries.push({
path: archivePath,
body: bytesToPortableFileEntry(archivePath, entryBytes),
});
}
offset = bodyEnd;
}
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, CompanyPortabilityFileEntry> = {};
for (const entry of entries) {
const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`)
? entry.path.slice(rootPath.length + 1)
: entry.path;
if (!normalizedPath) continue;
files[normalizedPath] = entry.body;
}
return { rootPath, files };
}

View File

@@ -63,6 +63,9 @@ function defaultConfig(): PaperclipConfig {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),
};

View File

@@ -33,6 +33,11 @@ import {
} from "../config/home.js";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import {
getTelemetryClient,
trackInstallStarted,
trackInstallCompleted,
} from "../telemetry.js";
type SetupMode = "quickstart" | "advanced";
@@ -244,11 +249,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
),
);
let existingConfig: PaperclipConfig | null = null;
if (configExists(opts.config)) {
p.log.message(pc.dim(`${configPath} exists, updating config`));
p.log.message(pc.dim(`${configPath} exists`));
try {
readConfig(opts.config);
existingConfig = readConfig(opts.config);
} catch (err) {
p.log.message(
pc.yellow(
@@ -258,6 +264,76 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
}
if (existingConfig) {
p.log.message(
pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."),
);
p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`));
const jwtSecret = ensureAgentJwtSecret(configPath);
const envFilePath = resolveAgentJwtEnvFile(configPath);
if (jwtSecret.created) {
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
} else {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
}
const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath);
if (keyResult.status === "created") {
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
} else if (keyResult.status === "existing") {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
}
p.note(
[
"Existing config preserved",
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
`Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`,
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
].join("\n"),
"Configuration ready",
);
p.note(
[
`Run: ${pc.cyan("paperclipai run")}`,
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
].join("\n"),
"Next commands",
);
let shouldRunNow = opts.run === true || opts.yes === true;
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
initialValue: true,
});
if (!p.isCancel(answer)) {
shouldRunNow = answer;
}
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
}
p.outro("Existing Paperclip setup is ready.");
return;
}
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
@@ -285,6 +361,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
setupMode = setupModeChoice as SetupMode;
}
const tc = getTelemetryClient();
if (tc) trackInstallStarted(tc);
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
let {
@@ -417,6 +496,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
logging,
server,
auth,
telemetry: {
enabled: true,
},
storage,
secrets,
};
@@ -430,6 +512,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
writeConfig(config, opts.config);
if (tc) trackInstallCompleted(tc, {
adapterType: server.deploymentMode,
});
p.note(
[
`Database: ${database.mode}`,

View File

@@ -0,0 +1,352 @@
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { Command } from "commander";
import pc from "picocolors";
import {
applyPendingMigrations,
createDb,
createEmbeddedPostgresLogBuffer,
ensurePostgresDatabase,
formatEmbeddedPostgresError,
routines,
} from "@paperclipai/db";
import { eq, inArray } from "drizzle-orm";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
type RoutinesDisableAllOptions = {
config?: string;
dataDir?: string;
companyId?: string;
json?: boolean;
};
type DisableAllRoutinesResult = {
companyId: string;
totalRoutines: number;
pausedCount: number;
alreadyPausedCount: number;
archivedCount: number;
};
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
type EmbeddedPostgresHandle = {
port: number;
startedByThisProcess: boolean;
stop: () => Promise<void>;
};
type ClosableDb = ReturnType<typeof createDb> & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function isPortAvailable(port: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const server = net.createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findAvailablePort(preferredPort: number): Promise<number> {
let port = Math.max(1, Math.trunc(preferredPort));
while (!(await isPortAvailable(port))) {
port += 1;
}
return port;
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!fs.existsSync(postmasterPidFile)) return null;
try {
const lines = fs.readFileSync(postmasterPidFile, "utf8").split("\n");
const port = Number(lines[3]?.trim());
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!fs.existsSync(postmasterPidFile)) return null;
try {
const pid = Number(fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
if (!Number.isInteger(pid) || pid <= 0) return null;
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor;
try {
const mod = await import(moduleName);
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
if (runningPid) {
return {
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
startedByThisProcess: false,
stop: async () => {},
};
}
const port = await findAvailablePort(preferredPort);
const logBuffer = createEmbeddedPostgresLogBuffer();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: logBuffer.append,
onError: logBuffer.append,
});
if (!fs.existsSync(path.resolve(dataDir, "PG_VERSION"))) {
try {
await instance.initialise();
} catch (error) {
throw formatEmbeddedPostgresError(error, {
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
}
if (fs.existsSync(postmasterPidFile)) {
fs.rmSync(postmasterPidFile, { force: true });
}
try {
await instance.start();
} catch (error) {
throw formatEmbeddedPostgresError(error, {
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
recentLogs: logBuffer.getRecentLogs(),
});
}
return {
port,
startedByThisProcess: true,
stop: async () => {
await instance.stop();
},
};
}
async function closeDb(db: ClosableDb): Promise<void> {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
async function openConfiguredDb(configPath: string): Promise<{
db: ClosableDb;
stop: () => Promise<void>;
}> {
const config = readConfig(configPath);
if (!config) {
throw new Error(`Config not found at ${configPath}.`);
}
let embeddedHandle: EmbeddedPostgresHandle | null = null;
try {
if (config.database.mode === "embedded-postgres") {
embeddedHandle = await ensureEmbeddedPostgres(
config.database.embeddedPostgresDataDir,
config.database.embeddedPostgresPort,
);
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
await applyPendingMigrations(connectionString);
const db = createDb(connectionString) as ClosableDb;
return {
db,
stop: async () => {
await closeDb(db);
if (embeddedHandle?.startedByThisProcess) {
await embeddedHandle.stop().catch(() => undefined);
}
},
};
}
const connectionString = nonEmpty(config.database.connectionString);
if (!connectionString) {
throw new Error(`Config at ${configPath} does not define a database connection string.`);
}
await applyPendingMigrations(connectionString);
const db = createDb(connectionString) as ClosableDb;
return {
db,
stop: async () => {
await closeDb(db);
},
};
} catch (error) {
if (embeddedHandle?.startedByThisProcess) {
await embeddedHandle.stop().catch(() => undefined);
}
throw error;
}
}
export async function disableAllRoutinesInConfig(
options: Pick<RoutinesDisableAllOptions, "config" | "companyId">,
): Promise<DisableAllRoutinesResult> {
const configPath = resolveConfigPath(options.config);
loadPaperclipEnvFile(configPath);
const companyId =
nonEmpty(options.companyId)
?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID)
?? null;
if (!companyId) {
throw new Error("Company ID is required. Pass --company-id or set PAPERCLIP_COMPANY_ID.");
}
const config = readConfig(configPath);
if (!config) {
throw new Error(`Config not found at ${configPath}.`);
}
let embeddedHandle: EmbeddedPostgresHandle | null = null;
let db: ClosableDb | null = null;
try {
if (config.database.mode === "embedded-postgres") {
embeddedHandle = await ensureEmbeddedPostgres(
config.database.embeddedPostgresDataDir,
config.database.embeddedPostgresPort,
);
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
await applyPendingMigrations(connectionString);
db = createDb(connectionString) as ClosableDb;
} else {
const connectionString = nonEmpty(config.database.connectionString);
if (!connectionString) {
throw new Error(`Config at ${configPath} does not define a database connection string.`);
}
await applyPendingMigrations(connectionString);
db = createDb(connectionString) as ClosableDb;
}
const existing = await db
.select({
id: routines.id,
status: routines.status,
})
.from(routines)
.where(eq(routines.companyId, companyId));
const alreadyPausedCount = existing.filter((routine) => routine.status === "paused").length;
const archivedCount = existing.filter((routine) => routine.status === "archived").length;
const idsToPause = existing
.filter((routine) => routine.status !== "paused" && routine.status !== "archived")
.map((routine) => routine.id);
if (idsToPause.length > 0) {
await db
.update(routines)
.set({
status: "paused",
updatedAt: new Date(),
})
.where(inArray(routines.id, idsToPause));
}
return {
companyId,
totalRoutines: existing.length,
pausedCount: idsToPause.length,
alreadyPausedCount,
archivedCount,
};
} finally {
if (db) {
await closeDb(db);
}
if (embeddedHandle?.startedByThisProcess) {
await embeddedHandle.stop().catch(() => undefined);
}
}
}
export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptions): Promise<void> {
const result = await disableAllRoutinesInConfig(options);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.totalRoutines === 0) {
console.log(pc.dim(`No routines found for company ${result.companyId}.`));
return;
}
console.log(
`Paused ${result.pausedCount} routine(s) for company ${result.companyId} ` +
`(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`,
);
}
export function registerRoutineCommands(program: Command): void {
const routinesCommand = program.command("routines").description("Local routine maintenance commands");
routinesCommand
.command("disable-all")
.description("Pause all non-archived routines in the configured local instance for one company")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("-C, --company-id <id>", "Company ID")
.option("--json", "Output raw JSON")
.action(async (opts: RoutinesDisableAllOptions) => {
try {
await disableAllRoutinesCommand(opts);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(pc.red(message));
process.exit(1);
}
});
}

View File

@@ -224,6 +224,9 @@ export function buildWorktreeConfig(input: {
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
disableSignUp: source?.auth.disableSignUp ?? false,
},
telemetry: {
enabled: source?.telemetry?.enabled ?? true,
},
storage: {
provider: source?.storage.provider ?? "local_disk",
localDisk: {

View File

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

View File

@@ -33,6 +33,10 @@ export function resolveDefaultContextPath(): string {
return path.resolve(resolvePaperclipHomeDir(), "context.json");
}
export function resolveDefaultCliAuthPath(): string {
return path.resolve(resolvePaperclipHomeDir(), "auth.json");
}
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
}

View File

@@ -7,6 +7,7 @@ export {
loggingConfigSchema,
serverConfigSchema,
authConfigSchema,
telemetryConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
@@ -19,10 +20,11 @@ export {
type LoggingConfig,
type ServerConfig,
type AuthConfig,
type TelemetryConfig,
type StorageConfig,
type StorageLocalDiskConfig,
type StorageS3Config,
type SecretsConfig,
type SecretsLocalEncryptedConfig,
type ConfigMeta,
} from "@paperclipai/shared";
} from "../../../packages/shared/src/config-schema.js";

View File

@@ -15,10 +15,15 @@ import { registerAgentCommands } from "./commands/client/agent.js";
import { registerApprovalCommands } from "./commands/client/approval.js";
import { registerActivityCommands } from "./commands/client/activity.js";
import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { registerRoutineCommands } from "./commands/routines.js";
import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
import { cliVersion } from "./version.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -27,7 +32,7 @@ const DATA_DIR_OPTION_HELP =
program
.name("paperclipai")
.description("Paperclip CLI — setup, diagnose, and configure your instance")
.version("0.2.7");
.version(cliVersion);
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
@@ -37,6 +42,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
hasContextOption: optionNames.has("context"),
});
loadPaperclipEnvFile(options.config);
initTelemetryFromConfigFile(options.config);
});
program
@@ -136,6 +142,8 @@ registerAgentCommands(program);
registerApprovalCommands(program);
registerActivityCommands(program);
registerDashboardCommands(program);
registerRoutineCommands(program);
registerFeedbackCommands(program);
registerWorktreeCommands(program);
registerPluginCommands(program);
@@ -151,7 +159,22 @@ auth
.option("--base-url <url>", "Public base URL used to print invite link")
.action(bootstrapCeoInvite);
program.parseAsync().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
registerClientAuthCommands(auth);
async function main(): Promise<void> {
let failed = false;
try {
await program.parseAsync();
} catch (err) {
failed = true;
console.error(err instanceof Error ? err.message : String(err));
} finally {
await flushTelemetry();
}
if (failed) {
process.exit(1);
}
}
void main();

49
cli/src/telemetry.ts Normal file
View File

@@ -0,0 +1,49 @@
import path from "node:path";
import {
TelemetryClient,
resolveTelemetryConfig,
loadOrCreateState,
trackInstallStarted,
trackInstallCompleted,
trackCompanyImported,
} from "../../packages/shared/src/telemetry/index.js";
import { resolvePaperclipInstanceRoot } from "./config/home.js";
import { readConfig } from "./config/store.js";
import { cliVersion } from "./version.js";
let client: TelemetryClient | null = null;
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
if (client) return client;
const config = resolveTelemetryConfig(fileConfig);
if (!config.enabled) return null;
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion);
return client;
}
export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null {
try {
return initTelemetry(readConfig(configPath)?.telemetry);
} catch {
return initTelemetry();
}
}
export function getTelemetryClient(): TelemetryClient | null {
return client;
}
export async function flushTelemetry(): Promise<void> {
if (client) {
await client.flush();
}
}
export {
trackInstallStarted,
trackInstallCompleted,
trackCompanyImported,
};

10
cli/src/version.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createRequire } from "node:module";
type PackageJson = {
version?: string;
};
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as PackageJson;
export const cliVersion = pkg.version ?? "0.0.0";

View File

@@ -2,7 +2,7 @@
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": ".."
},
"include": ["src"]
"include": ["src", "../packages/shared/src"]
}

View File

@@ -28,7 +28,7 @@ These define the contract between server, CLI, and UI.
| File | What it defines |
|---|---|
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, companies. |
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. |
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
@@ -37,7 +37,8 @@ These define the contract between server, CLI, and UI.
| File | Responsibility |
|---|---|
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, task recurrence parsing, and package README generation. References `agentcompanies/v1` version string. |
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. |
| `server/src/services/routines.ts` | Paperclip routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. |
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
@@ -60,7 +61,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
| File | Commands |
|---|---|
| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).<br>`company import` — imports a company package from a file or folder (flags: `--from`, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).<br>Reads/writes portable file entries and handles `.paperclip.yaml` filtering. |
| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).<br>`company import <fromPathOrUrl>` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).<br>Reads/writes portable file entries and handles `.paperclip.yaml` filtering. |
## 7. UI — Pages
@@ -106,7 +107,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
| `TASK.md` frontmatter & body | `company-portability.ts` |
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |

View File

@@ -39,6 +39,17 @@ This starts:
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server.
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
Inspect or stop the current repo's managed dev runner:
```sh
pnpm dev:list
pnpm dev:stop
```
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
Tailscale/private-auth dev mode:
@@ -86,7 +97,7 @@ docker run --name paperclip \
Or use Compose:
```sh
docker compose -f docker-compose.quickstart.yml up --build
docker compose -f docker/docker-compose.quickstart.yml up --build
```
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
@@ -134,6 +145,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins
- `~/.paperclip/instances/default/companies/<company-id>/codex-home`
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
@@ -162,6 +175,8 @@ Seed modes:
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
That repo-local env also sets:
- `PAPERCLIP_IN_WORKTREE=true`
@@ -206,6 +221,17 @@ paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force
```
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
```sh
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
pnpm paperclipai worktree init --force --seed-mode minimal \
--name PAP-884-ai-commits-component \
--from-config ~/.paperclip/instances/default/config.json
```
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |

View File

@@ -2,6 +2,28 @@
Run Paperclip in Docker without installing Node or pnpm locally.
All commands below assume you are in the **project root** (the directory containing `package.json`), not inside `docker/`.
## Building the image
```sh
docker build -t paperclip-local .
```
The Dockerfile installs common agent tools (`git`, `gh`, `curl`, `wget`, `ripgrep`, `python3`) and the Claude, Codex, and OpenCode CLIs.
Build arguments:
| Arg | Default | Purpose |
|-----|---------|---------|
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
| `USER_GID` | `1000` | GID for the container `node` group |
```sh
docker build -t paperclip-local \
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
```
## One-liner (build + run)
```sh
@@ -10,6 +32,7 @@ docker run --name paperclip \
-p 3100:3100 \
-e HOST=0.0.0.0 \
-e PAPERCLIP_HOME=/paperclip \
-e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
-v "$(pwd)/data/docker-paperclip:/paperclip" \
paperclip-local
```
@@ -25,10 +48,15 @@ Data persistence:
All persisted under your bind mount (`./data/docker-paperclip` in the example above).
## Compose Quickstart
## Docker Compose
### Quickstart (embedded SQLite)
Single container, no external database. Data persists via a bind mount.
```sh
docker compose -f docker-compose.quickstart.yml up --build
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
docker compose -f docker/docker-compose.quickstart.yml up --build
```
Defaults:
@@ -39,11 +67,36 @@ Defaults:
Optional overrides:
```sh
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
docker compose -f docker/docker-compose.quickstart.yml up --build
```
**Note:** `PAPERCLIP_DATA_DIR` is resolved relative to the compose file (`docker/`), so `../data/pc` maps to `data/pc` in the project root.
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.
Pass `OPENAI_API_KEY` and/or `ANTHROPIC_API_KEY` to enable local adapter runs.
### Full stack (with PostgreSQL)
Paperclip server + PostgreSQL 17. The database is health-checked before the server starts.
```sh
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
docker compose -f docker/docker-compose.yml up --build
```
PostgreSQL data persists in a named Docker volume (`pgdata`). Paperclip data persists in `paperclip-data`.
### Untrusted PR review
Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See `doc/UNTRUSTED-PR-REVIEW.md` for the full workflow.
```sh
docker compose -f docker/docker-compose.untrusted-review.yml build
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
```
## Authenticated Compose (Single Public URL)
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
@@ -93,11 +146,71 @@ Notes:
- Without API keys, the app still runs normally.
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
## Untrusted PR Review Container
## Podman Quadlet (systemd)
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
The `docker/quadlet/` directory contains unit files to run Paperclip + PostgreSQL as systemd services via Podman Quadlet.
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
| File | Purpose |
|------|---------|
| `docker/quadlet/paperclip.pod` | Pod definition — groups containers into a shared network namespace |
| `docker/quadlet/paperclip.container` | Paperclip server — joins the pod, connects to Postgres at `127.0.0.1` |
| `docker/quadlet/paperclip-db.container` | PostgreSQL 17 — joins the pod, health-checked |
### Setup
1. Build the image (see above).
2. Copy quadlet files to your systemd directory:
```sh
# Rootless (recommended)
cp docker/quadlet/*.pod docker/quadlet/*.container \
~/.config/containers/systemd/
# Or rootful
sudo cp docker/quadlet/*.pod docker/quadlet/*.container \
/etc/containers/systemd/
```
3. Create a secrets env file (keep out of version control):
```sh
cat > ~/.config/containers/systemd/paperclip.env <<EOL
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_USER=paperclip
POSTGRES_PASSWORD=paperclip
POSTGRES_DB=paperclip
DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/paperclip
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-...
EOL
```
4. Create the data directory and start:
```sh
mkdir -p ~/.local/share/paperclip
systemctl --user daemon-reload
systemctl --user start paperclip-pod
```
### Quadlet management
```sh
journalctl --user -u paperclip -f # App logs
journalctl --user -u paperclip-db -f # DB logs
systemctl --user status paperclip-pod # Pod status
systemctl --user restart paperclip-pod # Restart all
systemctl --user stop paperclip-pod # Stop all
```
### Quadlet notes
- **First boot**: Unlike Docker Compose's `condition: service_healthy`, Quadlet's `After=` only waits for the DB unit to *start*, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in `journalctl --user -u paperclip` while PostgreSQL initialises — this is expected and resolves automatically via `Restart=on-failure`.
- Containers in a pod share `localhost`, so Paperclip reaches Postgres at `127.0.0.1:5432`.
- PostgreSQL data persists in the `paperclip-pgdata` named volume.
- Paperclip data persists at `~/.local/share/paperclip`.
- For rootful quadlet deployment, remove `%h` prefixes and use absolute paths.
## Onboard Smoke Test (Ubuntu + npm only)
@@ -133,4 +246,9 @@ Notes:
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
- Set `SMOKE_DETACH=true` to leave the container running for automation and optionally write shell-ready metadata to `SMOKE_METADATA_FILE`.
- The image definition is in `Dockerfile.onboard-smoke`.
- The image definition is in `docker/Dockerfile.onboard-smoke`.
## General Notes
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).

View File

@@ -51,10 +51,9 @@ Public packages are discovered from:
- `packages/`
- `server/`
- `ui/`
- `cli/`
`ui/` is ignored because it is private.
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
- finds all public packages
@@ -65,6 +64,57 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
Those rewrites are temporary. The working tree is restored after publish or dry-run.
## `@paperclipai/ui` packaging
The UI package publishes prebuilt static assets, not the source workspace.
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
- keeps the release-managed `name` and `version`
- publishes only `dist/`
- omits the source-only dependency graph from downstream installs
After packing or publishing, `postpack` restores the development manifest automatically.
### Manual first publish for `@paperclipai/ui`
If you need to publish only the UI package once by hand, use the real package name:
- `@paperclipai/ui`
Recommended flow from the repo root:
```bash
# optional sanity check: this 404s until the first publish exists
npm view @paperclipai/ui version
# make sure the dist payload is fresh
pnpm --filter @paperclipai/ui build
# confirm your local npm auth before the real publish
npm whoami
# safe preview of the exact publish payload
cd ui
pnpm publish --dry-run --no-git-checks --access public
# real publish
pnpm publish --no-git-checks --access public
```
Notes:
- Publish from `ui/`, not the repo root.
- `prepack` automatically rewrites `ui/package.json` to the lean publish manifest, and `postpack` restores the dev manifest after the command finishes.
- If `npm view @paperclipai/ui version` already returns the same version that is in [`ui/package.json`](../ui/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
If the first real publish returns npm `E404`, check npm-side prerequisites before retrying:
- `npm whoami` must succeed first. An expired or missing npm login will block the publish.
- For an organization-scoped package like `@paperclipai/ui`, the `paperclipai` npm organization must exist and the publisher must be a member with permission to publish to that scope.
- The initial publish must include `--access public` for a public scoped package.
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
## Version formats
Paperclip uses calendar versions:
@@ -135,6 +185,7 @@ This is the fastest way to restore the default install path if a stable release
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

View File

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

View File

@@ -860,11 +860,15 @@ Export/import behavior in V1:
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
- projects and starter tasks are opt-in export content rather than default package content
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication)
- recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml`
- Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml`
- export never includes secret values; env inputs are reported as portable declarations instead
- import supports target modes:
- create a new company
- import into an existing company
- import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids
- import forces imported agent timer heartbeats off so packages never start scheduled runs implicitly
- import supports collision strategies: `rename`, `skip`, `replace`
- import supports preview (dry-run) before apply
- GitHub imports warn on unpinned refs instead of blocking

View File

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

View File

@@ -16,14 +16,14 @@ By default this workflow does **not** mount your host repo checkout, your host h
## Files
- `docker/untrusted-review/Dockerfile`
- `docker-compose.untrusted-review.yml`
- `docker/docker-compose.untrusted-review.yml`
- `review-checkout-pr` inside the container
## Build and start a shell
```sh
docker compose -f docker-compose.untrusted-review.yml build
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
docker compose -f docker/docker-compose.untrusted-review.yml build
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
```
That opens an interactive shell in the review container with:
@@ -47,7 +47,7 @@ claude login
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
```sh
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker/docker-compose.untrusted-review.yml run --rm review
```
## Check out a PR safely
@@ -117,7 +117,7 @@ Notes:
Remove the review container volumes when you want a clean environment:
```sh
docker compose -f docker-compose.untrusted-review.yml down -v
docker compose -f docker/docker-compose.untrusted-review.yml down -v
```
That deletes:

View File

@@ -484,8 +484,8 @@ The CLI should continue to support direct import/export without a registry.
Target commands:
- `paperclipai company export <company-id> --out <path>`
- `paperclipai company import --from <path-or-url> --dry-run`
- `paperclipai company import --from <path-or-url> --target existing -C <company-id>`
- `paperclipai company import <path-or-url> --dry-run`
- `paperclipai company import <path-or-url> --target existing -C <company-id>`
Planned additions:

View File

@@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
"cwd": "/absolute/or/relative/path",
"promptTemplate": "You are agent {{agent.id}} ...",
"model": "optional-model-id",
"maxTurnsPerRun": 300,
"maxTurnsPerRun": 1000,
"dangerouslySkipPermissions": true,
"env": {"KEY": "VALUE"},
"extraArgs": [],

View File

@@ -1,7 +1,7 @@
services:
paperclip:
build:
context: .
context: ..
dockerfile: Dockerfile
ports:
- "${PAPERCLIP_PORT:-3100}:3100"
@@ -15,4 +15,4 @@ services:
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"
- "${PAPERCLIP_DATA_DIR:-../data/docker-paperclip}:/paperclip"

View File

@@ -1,7 +1,7 @@
services:
review:
build:
context: .
context: ..
dockerfile: docker/untrusted-review/Dockerfile
init: true
tty: true

View File

@@ -16,7 +16,9 @@ services:
- pgdata:/var/lib/postgresql/data
server:
build: .
build:
context: ..
dockerfile: Dockerfile
ports:
- "3100:3100"
environment:

View File

@@ -0,0 +1,20 @@
[Unit]
Description=PostgreSQL for Paperclip
[Container]
Image=docker.io/library/postgres:17-alpine
ContainerName=paperclip-db
Pod=paperclip.pod
Volume=paperclip-pgdata:/var/lib/postgresql/data
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h localhost || exit 1
HealthInterval=15s
HealthTimeout=5s
HealthRetries=5
[Service]
Restart=on-failure
TimeoutStartSec=60
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Paperclip AI Agent Orchestrator
Requires=paperclip-db.service
After=paperclip-db.service
[Container]
Image=paperclip-local
ContainerName=paperclip
Pod=paperclip.pod
Volume=%h/.local/share/paperclip:/paperclip:Z
Environment=HOST=0.0.0.0
Environment=PAPERCLIP_HOME=/paperclip
Environment=PAPERCLIP_DEPLOYMENT_MODE=authenticated
Environment=PAPERCLIP_DEPLOYMENT_EXPOSURE=private
Environment=PAPERCLIP_PUBLIC_URL=http://localhost:3100
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
[Service]
Restart=on-failure
TimeoutStartSec=120
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,3 @@
[Pod]
PodName=paperclip
PublishPort=3100:3100

View File

@@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
| `graceSec` | number | No | Grace period before force-kill |
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) |
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
## Prompt Templates

View File

@@ -40,6 +40,12 @@ 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.
## Instructions Resolution
If `instructionsFilePath` is configured, Paperclip reads that file and prepends it to the stdin prompt sent to `codex exec` on every run.
This is separate from any workspace-level instruction discovery that Codex itself performs in the run `cwd`. Paperclip does not disable Codex-native repo instruction files, so a repo-local `AGENTS.md` may still be loaded by Codex in addition to the Paperclip-managed agent instructions.
## Environment Test
The environment test checks:

View File

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

View File

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

View File

@@ -41,15 +41,16 @@ pnpm paperclipai company export <company-id> --out ./exports/acme --include comp
# Preview import (no writes)
pnpm paperclipai company import \
--from https://github.com/<owner>/<repo>/tree/main/<path> \
<owner>/<repo>/<path> \
--target existing \
--company-id <company-id> \
--ref main \
--collision rename \
--dry-run
# Apply import
pnpm paperclipai company import \
--from ./exports/acme \
./exports/acme \
--target new \
--new-company-name "Acme Imported" \
--include company,agents

View File

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

View File

@@ -253,17 +253,7 @@ owner: cto
name: Monday Review
assignee: ceo
project: q2-launch
schedule:
timezone: America/Chicago
startsAt: 2026-03-16T09:00:00-05:00
recurrence:
frequency: weekly
interval: 1
weekdays:
- monday
time:
hour: 9
minute: 0
recurring: true
```
### Semantics
@@ -271,58 +261,30 @@ schedule:
- body content is the canonical markdown task description
- `assignee` should reference an agent slug inside the package
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence
- `recurring: true` marks the task as ongoing recurring work instead of a one-time starter task
- tasks are intentionally basic seed work: title, markdown body, assignee, project linkage, and optional `recurring: true`
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
### Scheduling
### Recurring Tasks
The scheduling model is intentionally lightweight. It should cover common recurring patterns such as:
- the base package only needs to say whether a task is recurring
- vendors may attach the actual schedule / trigger / runtime fidelity in a vendor extension such as `.paperclip.yaml`
- this keeps `TASK.md` portable while still allowing richer runtime systems to round-trip their own automation details
- legacy packages may still use `schedule.recurrence` during transition, but exporters should prefer `recurring: true`
- every 6 hours
- every weekday at 9:00
- every Monday morning
- every month on the 1st
- every first Monday of the month
- every year on January 1
Suggested shape:
Example Paperclip extension:
```yaml
schedule:
timezone: America/Chicago
startsAt: 2026-03-14T09:00:00-05:00
recurrence:
frequency: hourly | daily | weekly | monthly | yearly
interval: 1
weekdays:
- monday
- wednesday
monthDays:
- 1
- 15
ordinalWeekdays:
- weekday: monday
ordinal: 1
months:
- 1
- 6
time:
hour: 9
minute: 0
until: 2026-12-31T23:59:59-06:00
count: 10
routines:
monday-review:
triggers:
- kind: schedule
cronExpression: "0 9 * * 1"
timezone: America/Chicago
```
Rules:
- `timezone` should use an IANA timezone like `America/Chicago`
- `startsAt` anchors the first occurrence
- `frequency` and `interval` are the only required recurrence fields
- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules
- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last”
- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable
- `until` and `count` are optional recurrence end bounds
- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above
- vendors should ignore unknown recurring-task extensions they do not understand
- vendors importing legacy `schedule.recurrence` data may translate it into their own runtime trigger model, but new exports should prefer the simpler `recurring: true` base field
## 11. SKILL.md Compatibility
@@ -449,7 +411,7 @@ Suggested import UI behavior:
- selecting an agent auto-selects required docs and referenced skills
- selecting a team auto-selects its subtree
- selecting a project auto-selects its included tasks
- selecting a recurring task should surface its schedule before import
- selecting a recurring task should make it clear that the import target is a routine / automation, not a one-time task
- selecting referenced third-party content shows attribution, license, and fetch policy
## 15. Vendor Extensions
@@ -502,6 +464,12 @@ agents:
kind: plain
requirement: optional
default: claude
routines:
monday-review:
triggers:
- kind: schedule
cronExpression: "0 9 * * 1"
timezone: America/Chicago
```
Additional rules for Paperclip exporters:
@@ -520,7 +488,7 @@ A compliant exporter should:
- omit machine-local ids and timestamps
- omit secret values
- omit machine-specific paths
- preserve task descriptions and recurrence definitions when exporting tasks
- preserve task descriptions and recurring-task declarations when exporting tasks
- omit empty/default fields
- default to the vendor-neutral base package
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
@@ -569,11 +537,11 @@ Paperclip can map this spec to its runtime model like this:
- `TEAM.md` -> importable org subtree
- `AGENTS.md` -> agent identity and instructions
- `PROJECT.md` -> starter project definition
- `TASK.md` -> starter issue/task definition, or automation template when recurrence is present
- `TASK.md` -> starter issue/task definition, or recurring task template when `recurring: true`
- `SKILL.md` -> imported skill package
- `sources[]` -> provenance and pinned upstream refs
- Paperclip extension:
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity
Inline Paperclip-only metadata that must live inside a shared markdown file should use:

View File

@@ -8,7 +8,7 @@ Run Paperclip in Docker without installing Node or pnpm locally.
## Compose Quickstart (Recommended)
```sh
docker compose -f docker-compose.quickstart.yml up --build
docker compose -f docker/docker-compose.quickstart.yml up --build
```
Open [http://localhost:3100](http://localhost:3100).
@@ -21,10 +21,12 @@ Defaults:
Override with environment variables:
```sh
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc \
docker compose -f docker-compose.quickstart.yml up --build
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
docker compose -f docker/docker-compose.quickstart.yml up --build
```
**Note:** `PAPERCLIP_DATA_DIR` is resolved relative to the compose file (`docker/`), so `../data/pc` maps to `data/pc` in the project root.
## Manual Docker Build
```sh

View File

@@ -46,9 +46,12 @@
"guides/board-operator/managing-agents",
"guides/board-operator/org-structure",
"guides/board-operator/managing-tasks",
"guides/board-operator/execution-workspaces-and-runtime-services",
"guides/board-operator/delegation",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log"
"guides/board-operator/activity-log",
"guides/board-operator/importing-and-exporting"
]
},
{

189
docs/feedback-voting.md Normal file
View File

@@ -0,0 +1,189 @@
# Feedback Voting — Local Data Guide
When you rate an agent's response with **Helpful** (thumbs up) or **Needs work** (thumbs down), Paperclip saves your vote locally alongside your running instance. This guide covers what gets stored, how to access it, and how to export it.
## How voting works
1. Click **Helpful** or **Needs work** on any agent comment or document revision.
2. If you click **Needs work**, an optional text prompt appears: _"What could have been better?"_ You can type a reason or dismiss it.
3. A consent dialog asks whether to keep the vote local or share it. Your choice is remembered for future votes.
### What gets stored
Each vote creates two local records:
| Record | What it contains |
|--------|-----------------|
| **Vote** | Your vote (up/down), optional reason text, sharing preference, consent version, timestamp |
| **Trace bundle** | Full context snapshot: the voted-on comment/revision text, issue title, agent info, your vote, and reason — everything needed to understand the feedback in isolation |
All data lives in your local Paperclip database. Nothing leaves your machine unless you explicitly choose to share.
When a vote is marked for sharing, Paperclip also queues the trace bundle for background export through the Telemetry Backend. The app server never uploads raw feedback trace bundles directly to object storage.
## Viewing your votes
### Quick report (terminal)
```bash
pnpm paperclipai feedback report
```
Shows a color-coded summary: vote counts, per-trace details with reasons, and export statuses.
```bash
# Installed CLI
paperclipai feedback report
# Point to a different server or company
pnpm paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
# Include raw payload dumps in the report
pnpm paperclipai feedback report --payloads
```
### API endpoints
All endpoints require board-user access (automatic in local dev).
**List votes for an issue:**
```bash
curl http://127.0.0.1:3102/api/issues/<issueId>/feedback-votes
```
**List trace bundles for an issue (with full payloads):**
```bash
curl 'http://127.0.0.1:3102/api/issues/<issueId>/feedback-traces?includePayload=true'
```
**List all traces company-wide:**
```bash
curl 'http://127.0.0.1:3102/api/companies/<companyId>/feedback-traces?includePayload=true'
```
**Get a single trace envelope record:**
```bash
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>
```
**Get the full export bundle for a trace:**
```bash
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>/bundle
```
#### Filtering
The trace endpoints accept query parameters:
| Parameter | Values | Description |
|-----------|--------|-------------|
| `vote` | `up`, `down` | Filter by vote direction |
| `status` | `local_only`, `pending`, `sent`, `failed` | Filter by export status |
| `targetType` | `issue_comment`, `issue_document_revision` | Filter by what was voted on |
| `sharedOnly` | `true` | Only show votes the user chose to share |
| `includePayload` | `true` | Include the full context snapshot |
| `from` / `to` | ISO date | Date range filter |
## Exporting your data
### Export to files + zip
```bash
pnpm paperclipai feedback export
```
Creates a timestamped directory with:
```
feedback-export-20260331T120000Z/
index.json # manifest with summary stats
votes/
PAP-123-a1b2c3d4.json # vote metadata (one per vote)
traces/
PAP-123-e5f6g7h8.json # Paperclip feedback envelope (one per trace)
full-traces/
PAP-123-e5f6g7h8/
bundle.json # full export manifest for the trace
...raw adapter files # codex / claude / opencode session artifacts when available
feedback-export-20260331T120000Z.zip
```
Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files.
```bash
# Custom server and output directory
pnpm paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
```
### Reading an exported trace
Open any file in `traces/` to see:
```json
{
"id": "trace-uuid",
"vote": "down",
"issueIdentifier": "PAP-123",
"issueTitle": "Fix login timeout",
"targetType": "issue_comment",
"targetSummary": {
"label": "Comment",
"excerpt": "The first 80 chars of the comment that was voted on..."
},
"payloadSnapshot": {
"vote": {
"value": "down",
"reason": "Did not address the root cause"
},
"target": {
"body": "Full text of the agent comment..."
},
"issue": {
"identifier": "PAP-123",
"title": "Fix login timeout"
}
}
}
```
Open `full-traces/<issue>-<trace>/bundle.json` to see the expanded export metadata, including capture notes, adapter type, integrity metadata, and the inventory of raw files written alongside it.
Built-in local adapters now export their native session artifacts more directly:
- `codex_local`: `adapter/codex/session.jsonl`
- `claude_local`: `adapter/claude/session.jsonl`, plus any `adapter/claude/session/...` sidecar files and `adapter/claude/debug.txt` when present
- `opencode_local`: `adapter/opencode/session.json`, `adapter/opencode/messages/*.json`, and `adapter/opencode/parts/<messageId>/*.json`, with optional `project.json`, `todo.json`, and `session-diff.json`
## Sharing preferences
The first time you vote, a consent dialog asks:
- **Keep local** — vote is stored locally only (`sharedWithLabs: false`)
- **Share this vote** — vote is marked for sharing (`sharedWithLabs: true`)
Your preference is saved per-company. You can change it any time via the feedback settings. Votes marked "keep local" are never queued for export.
## Data lifecycle
| Status | Meaning |
|--------|---------|
| `local_only` | Vote stored locally, not marked for sharing |
| `pending` | Marked for sharing, waiting to be sent |
| `sent` | Successfully transmitted |
| `failed` | Transmission attempted but failed (will retry) |
Your local database always retains the full vote and trace data regardless of sharing status.
## Remote sync
Votes you choose to share are queued as `pending` traces and flushed by the server's background worker to the Telemetry Backend. The Telemetry Backend validates the request, then persists the bundle into its configured object storage.
- App server responsibility: build the bundle, POST it to Telemetry Backend, update trace status
- Telemetry Backend responsibility: authenticate the request, validate payload shape, compress/store the bundle, return the final object key
- Retry behavior: failed uploads move to `failed` with an error message in `failureReason`, and the worker retries them on later ticks
Exported objects use a deterministic key pattern so they are easy to inspect:
```text
feedback-traces/<companyId>/YYYY/MM/DD/<exportId-or-traceId>.json
```

View File

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

View File

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

View File

@@ -0,0 +1,203 @@
---
title: Importing & Exporting Companies
summary: Export companies to portable packages and import them from local paths or GitHub
---
Paperclip companies can be exported to portable markdown packages and imported from local directories or GitHub repositories. This lets you share company configurations, duplicate setups, and version-control your agent teams.
## Package Format
Exported packages follow the [Agent Companies specification](/companies/companies-spec) and use a markdown-first structure:
```text
my-company/
├── COMPANY.md # Company metadata
├── agents/
│ ├── ceo/AGENT.md # Agent instructions + frontmatter
│ └── cto/AGENT.md
├── projects/
│ └── main/PROJECT.md
├── skills/
│ └── review/SKILL.md
├── tasks/
│ └── onboarding/TASK.md
└── .paperclip.yaml # Adapter config, env inputs, routines
```
- **COMPANY.md** defines company name, description, and metadata.
- **AGENT.md** files contain agent identity, role, and instructions.
- **SKILL.md** files are compatible with the Agent Skills ecosystem.
- **.paperclip.yaml** holds Paperclip-specific config (adapter types, env inputs, budgets) as an optional sidecar.
## Exporting a Company
Export a company into a portable folder:
```sh
paperclipai company export <company-id> --out ./my-export
```
### Options
| Option | Description | Default |
|--------|-------------|---------|
| `--out <path>` | Output directory (required) | — |
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` |
| `--skills <values>` | Export only specific skill slugs | all |
| `--projects <values>` | Export only specific project shortnames or IDs | all |
| `--issues <values>` | Export specific issue identifiers or IDs | none |
| `--project-issues <values>` | Export issues belonging to specific projects | none |
| `--expand-referenced-skills` | Vendor skill file contents instead of keeping upstream references | `false` |
### Examples
```sh
# Export company with agents and projects
paperclipai company export abc123 --out ./backup --include company,agents,projects
# Export everything including tasks and skills
paperclipai company export abc123 --out ./full-export --include company,agents,projects,tasks,skills
# Export only specific skills
paperclipai company export abc123 --out ./skills-only --include skills --skills review,deploy
```
### What Gets Exported
- Company name, description, and metadata
- Agent names, roles, reporting structure, and instructions
- Project definitions and workspace config
- Task/issue descriptions (when included)
- Skill packages (as references or vendored content)
- Adapter type and env input declarations in `.paperclip.yaml`
Secret values, machine-local paths, and database IDs are **never** exported.
## Importing a Company
Import from a local directory, GitHub URL, or GitHub shorthand:
```sh
# From a local folder
paperclipai company import ./my-export
# From a GitHub URL
paperclipai company import https://github.com/org/repo
# From a GitHub subfolder
paperclipai company import https://github.com/org/repo/tree/main/companies/acme
# From GitHub shorthand
paperclipai company import org/repo
paperclipai company import org/repo/companies/acme
```
### Options
| Option | Description | Default |
|--------|-------------|---------|
| `--target <mode>` | `new` (create a new company) or `existing` (merge into existing) | inferred from context |
| `--company-id <id>` | Target company ID for `--target existing` | current context |
| `--new-company-name <name>` | Override company name for `--target new` | from package |
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected |
| `--agents <list>` | Comma-separated agent slugs to import, or `all` | `all` |
| `--collision <mode>` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` |
| `--ref <value>` | Git ref for GitHub imports (branch, tag, or commit) | default branch |
| `--dry-run` | Preview what would be imported without applying | `false` |
| `--yes` | Skip the interactive confirmation prompt | `false` |
| `--json` | Output result as JSON | `false` |
### Target Modes
- **`new`** — Creates a fresh company from the package. Good for duplicating a company template.
- **`existing`** — Merges the package into an existing company. Use `--company-id` to specify the target.
If `--target` is not specified, Paperclip infers it: if a `--company-id` is provided (or one exists in context), it defaults to `existing`; otherwise `new`.
### Collision Strategies
When importing into an existing company, agent or project names may conflict with existing ones:
- **`rename`** (default) — Appends a suffix to avoid conflicts (e.g., `ceo` becomes `ceo-2`).
- **`skip`** — Skips entities that already exist.
- **`replace`** — Overwrites existing entities. Only available for non-safe imports (not available through the CEO API).
### Interactive Selection
When running interactively (no `--yes` or `--json` flags), the import command shows a selection picker before applying. You can choose exactly which agents, projects, skills, and tasks to import using a checkbox interface.
### Preview Before Applying
Always preview first with `--dry-run`:
```sh
paperclipai company import org/repo --target existing --company-id abc123 --dry-run
```
The preview shows:
- **Package contents** — How many agents, projects, tasks, and skills are in the source
- **Import plan** — What will be created, renamed, skipped, or replaced
- **Env inputs** — Environment variables that may need values after import
- **Warnings** — Potential issues like missing skills or unresolved references
Imported agents always land with timer heartbeats disabled. Assignment/on-demand wake behavior from the package is preserved, but scheduled runs stay off until a board operator re-enables them.
### Common Workflows
**Clone a company template from GitHub:**
```sh
paperclipai company import org/company-templates/engineering-team \
--target new \
--new-company-name "My Engineering Team"
```
**Add agents from a package into your existing company:**
```sh
paperclipai company import ./shared-agents \
--target existing \
--company-id abc123 \
--include agents \
--collision rename
```
**Import a specific branch or tag:**
```sh
paperclipai company import org/repo --ref v2.0.0 --dry-run
```
**Non-interactive import (CI/scripts):**
```sh
paperclipai company import ./package \
--target new \
--yes \
--json
```
## API Endpoints
The CLI commands use these API endpoints under the hood:
| Action | Endpoint |
|--------|----------|
| Export company | `POST /api/companies/{companyId}/export` |
| Preview import (existing company) | `POST /api/companies/{companyId}/imports/preview` |
| Apply import (existing company) | `POST /api/companies/{companyId}/imports/apply` |
| Preview import (new company) | `POST /api/companies/import/preview` |
| Apply import (new company) | `POST /api/companies/import` |
CEO agents can also use the safe import routes (`/imports/preview` and `/imports/apply`) which enforce non-destructive rules: `replace` is rejected, collisions resolve with `rename` or `skip`, and issues are always created as new.
## GitHub Sources
Paperclip supports several GitHub URL formats:
- Full URL: `https://github.com/org/repo`
- Subfolder URL: `https://github.com/org/repo/tree/main/path/to/company`
- Shorthand: `org/repo`
- Shorthand with path: `org/repo/path/to/company`
Use `--ref` to pin to a specific branch, tag, or commit hash when importing from GitHub.

View File

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

View File

@@ -9,6 +9,7 @@ Paperclip enforces a strict organizational hierarchy. Every agent reports to exa
- The **CEO** has no manager (reports to the board/human operator)
- Every other agent has a `reportsTo` field pointing to their manager
- You can change an agents manager after creation from **Agent → Configuration → Reports to** (or via `PATCH /api/agents/{id}` with `reportsTo`)
- Managers can create subtasks and delegate to their reports
- Agents escalate blockers up the chain of command

View File

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

View File

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

View File

@@ -3,9 +3,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
"dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev",
"dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list",
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"build": "pnpm -r build",
@@ -32,11 +34,12 @@
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
},
"devDependencies": {
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
@@ -44,5 +47,10 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@9.15.4"
"packageManager": "pnpm@9.15.4",
"pnpm": {
"patchedDependencies": {
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,11 @@ import {
buildPaperclipEnv,
readPaperclipRuntimeSkillEntries,
joinPromptSections,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
resolveCommandForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -68,11 +69,13 @@ interface ClaudeExecutionInput {
interface ClaudeRuntimeConfig {
command: string;
resolvedCommand: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
loggedEnv: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
@@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@@ -247,11 +256,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
return {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@@ -324,11 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@@ -352,7 +365,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const combinedPath = path.join(skillsDir, "agent-instructions.md");
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
effectiveInstructionsFilePath = combinedPath;
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -441,11 +453,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "claude_local",
command,
command: resolvedCommand,
cwd,
commandArgs: args,
commandNotes,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View File

@@ -24,7 +24,7 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
@@ -32,7 +32,7 @@ Core fields:
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
@@ -40,7 +40,9 @@ Operational fields:
Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.

View File

@@ -9,19 +9,20 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -135,8 +136,8 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks(
}
}
function resolveCodexWorkspaceSkillsDir(cwd: string): string {
return path.join(cwd, ".agents", "skills");
function resolveCodexSkillsDir(codexHome: string): string {
return path.join(codexHome, "skills");
}
type EnsureCodexSkillsInjectedOptions = {
@@ -157,7 +158,7 @@ export async function ensureCodexSkillsInjected(
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd());
const skillsHome = options.skillsHome ?? resolveCodexSkillsDir(resolveSharedCodexHomeDir());
await fs.mkdir(skillsHome, { recursive: true });
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
@@ -273,11 +274,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
// Inject skills into the same CODEX_HOME that Codex will actually run with
// (managed home in the default case, or an explicit override from adapter config).
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
await ensureCodexSkillsInjected(
onLog,
{
skillsHome: codexWorkspaceSkillsDir,
skillsHome: codexSkillsDir,
skillsEntries: codexSkillEntries,
desiredSkillNames,
},
@@ -381,6 +384,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@@ -415,10 +424,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -427,16 +432,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
const repoAgentsNote =
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
const commandNotes = (() => {
if (!instructionsFilePath) return [] as string[];
if (!instructionsFilePath) {
return [repoAgentsNote];
}
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
repoAgentsNote,
];
}
return [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
repoAgentsNote,
];
})();
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
@@ -486,14 +497,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View File

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

View File

@@ -107,8 +107,8 @@ function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string
return { email: null, planType: null };
}
export async function readCodexAuthInfo(): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHomeDir(), "auth.json");
export async function readCodexAuthInfo(codexHome?: string): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHome ?? codexHomeDir(), "auth.json");
let raw: string;
try {
raw = await fs.readFile(authPath, "utf8");
@@ -432,6 +432,13 @@ class CodexRpcClient {
}
this.pending.clear();
});
this.proc.on("error", (err: Error) => {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(err);
}
this.pending.clear();
});
}
private onStdout(chunk: string) {

View File

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

View File

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

View File

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

View File

@@ -9,12 +9,13 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
@@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@@ -307,10 +314,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -387,11 +390,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "cursor",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View File

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

View File

@@ -10,16 +10,17 @@ import {
asString,
asStringArray,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -220,6 +221,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@@ -253,10 +260,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -337,13 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "gemini_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View File

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

View File

@@ -1,7 +1,15 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const models: Array<{ id: string; label: string }> = [];
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const agentConfigurationDoc = `# opencode_local agent configuration
@@ -21,7 +29,8 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max)
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"
- extraArgs (string[], optional): additional CLI args
@@ -37,4 +46,10 @@ Notes:
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
- Runs are executed with: opencode run --format json ...
- Sessions are resumed with --session when stored session cwd matches current cwd.
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
writing an opencode.json config file into the project working directory. Model \
selection is passed via the --model CLI flag instead.
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
runtime config with \`permission.external_directory=allow\` so headless runs do \
not stall on approval prompts.
`;

View File

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

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