Compare commits

...

69 Commits

Author SHA1 Message Date
Chris Farhood e559218f98 fork: add Gitea/Forgejo source support for company skills
Reintroduce Gitea/Forgejo as a skill import source on dev only, since
the fork deploys against git.farh.net. Pasting a Gitea/Forgejo repo
URL into the skills sidebar mirrors the existing GitHub experience:
pin to a commit SHA, check for updates, read repo files.

Server: new gitea-fetch.ts (URL builders, probe-cache helpers) and
gitea-skills.ts (parse, probe, pin, tree, text, branch). Dispatch in
readUrlSkillImports probes /api/v1/version and routes non-github.com
hosts into the new readGiteaUrlSkillImports branch. updateStatus and
readFile get a gitea arm alongside the github/skills_sh arm. Audit
falls through to "remote not supported" the same way github does.

UI: Server icon, Gitea source label, gitea in the "external" source
class, Pin/Update UI gate widened to sourceType === "gitea". CLI help
text updated. Existing github code is left byte-for-byte unchanged
(wrapped in isGitHubDotCom) so dev <-> master syncs stay clean.

PAT support, gitea portability descriptors, and gitea audit are
deliberate follow-ups. Detection requires /api/v1/version to return
Gitea-shaped JSON; the per-host result is cached for process lifetime
with FIFO eviction at 1024 entries. Non-Gitea hosts fall through to
the existing raw-markdown url branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 15:30:44 -04:00
Chris Farhood 9f3f71a199 fork: add CLAUDE.md describing fork model and don'ts
Captures branch model (master/dev/local), the 3-file fork delta, upstream
sync procedure, and the post-reset rule against re-introducing fork code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 08:35:03 -04:00
Chris Farhood 1a1c57461f fork: production Dockerfile additions + Gitea registry build workflows
Only fork divergence from upstream/master. Adds to the production stage:
  - kubectl, kubeseal (Kubernetes ops in deployed pods)
  - uv, uvx (Python tooling)
  - forgejo-cli (fj, fj-ex, fgj)
  - gitea tea CLI
  - mmx-cli
  - nano, vim

Workflows push to git.farh.net/farhoodlabs/paperclip{,-dev}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 08:11:45 -04:00
Devin Foley 911a1e8b0d Fix continuation recovery retry streaks by failure cause (#7031)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The recovery subsystem is responsible for keeping assigned work
moving when a live heartbeat run disappears or fails.
> - `continuation_recovery` is the path that re-enqueues stranded
`in_progress` issues after an interrupted continuation attempt.
> - That path recently gained cause-aware retry classes and transient
retry caps, but the streak counter was still aggregating mixed failure
causes into one retry history.
> - That meant a sequence like `timeout -> timeout -> adapter_failed ->
adapter_failed` could escalate as a false `3x adapter_failed` streak
even though the latest cause had only happened twice.
> - This pull request makes continuation retry streaks count only
consecutive failures whose `errorCode` matches the latest run and adds a
regression test for the mixed-cause case.
> - The benefit is that transient retry backoff and escalation now match
the actual current failure cause instead of inheriting stale budget from
unrelated failures.

## What Changed

- Updated `summarizeRecentContinuationRetries(...)` to stop counting as
soon as the continuation failure cause no longer matches the latest
run's `errorCode`.
- Wired the continuation recovery escalation/backoff path to pass the
latest classified `errorCode` into the retry streak summarizer.
- Added a regression test proving mixed-cause continuation failures do
not consume the transient retry cap for a new failure cause.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts`

## Risks

- Low risk. The behavioral change is intentionally narrow, but any
future continuation retry modes that rely on `errorCode = null` will now
be counted as a separate streak bucket and should be kept in mind when
adding new retry classifications.

## Model Used

- OpenAI Codex via Paperclip `codex_local` (GPT-5-based Codex coding
agent; exact backend revision is not surfaced in the runtime), with tool
use, shell execution, and patch application in the local repository.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 19:48:59 -07:00
Devin Foley aea35fe695 exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path

> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.

## What Changed

- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.

## Verification

CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.

Run locally:

```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.

# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```

Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):

1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).

## Risks

- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination

## Checklist

- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00
Dotta 8014445b23 Add v2026.529.0 release changelog (#6999)
## Release changelog: v2026.529.0

Stable changelog for the **v2026.529.0** release (released 2026-05-29),
generated with the `release-changelog` skill.

- Range: `v2026.525.0..origin/master` — 11 squash-merged PRs
- Adds `releases/v2026.529.0.md`
- **No breaking changes** — migrations are additive (`CREATE TABLE IF
NOT EXISTS`); the only `DROP CONSTRAINT` lines are FK adjustments, not
data loss
- **No external contributors** this cycle — all PR authors are Paperclip
founders, who are excluded from the Contributors section per the skill,
so that section is omitted

### Highlights
- Inline document annotations and comments (#6733)
- Company skills CLI and catalog management (#6782)
- Hide projects and agents from your sidebar (#6677)
- First-admin claim flow for fresh self-hosted deployments (#6755)
- Live Claude model discovery (#6953)

### Improvements
- Bundled plugins now appear in the plugin manager (#6734)
- Tighter workspace lifecycle guarantees (#6969)

### Fixes
- Accepted plans decompose exactly once (#6831)

Docs-only (README brand/license #6810, #6804) and CI-only (#6967)
changes were excluded as not materially user-facing.

Issue: PAP-10155

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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 07:27:55 -10:00
Dotta 5153b01ada [codex] Add Claude model refresh (#6953)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through adapter-backed
local and external runtimes.
> - The agent configuration UI lets operators choose adapter models and
refresh model lists when adapters support live discovery.
> - Codex already had a live refresh path, but Claude Local only exposed
static fallback models and the UI hid the refresh action for Claude.
> - A newly available Claude Opus model should not require a code
release every time the model catalog changes.
> - This pull request adds Anthropic model discovery for Claude Local,
keeps the static fallback current with Claude Opus 4.8, and exposes the
existing refresh button in the Claude Local dropdown.
> - The benefit is that operators can refresh Claude models from the
same model selector flow they already use for Codex.

## What Changed

- Added `claude-opus-4-8` to the Claude Local fallback model list.
- Added Claude model discovery through Anthropic-compatible `GET
/v1/models` when `ANTHROPIC_API_KEY` is available.
- Added normal cache reuse, forced refresh support, a SHA-256-based
API-key fingerprint for cache keys, and warning logging for discovery
errors before fallback.
- Wired `claude_local.refreshModels` into the server adapter registry.
- Enabled the existing `Refresh models` dropdown action for
`claude_local` in `AgentConfigForm`.
- Added tests for Claude fallback, live discovery, API-failure fallback,
forced refresh, and the UI refresh-button gate.

## Verification

- `pnpm exec vitest run server/src/__tests__/adapter-models.test.ts`
- `pnpm exec vitest run ui/src/components/AgentConfigForm.test.ts`
- `pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- Greptile review reached Confidence Score: 5/5 on commit `b796cf4f1`
with addressed threads resolved.

UI note: the visible change is a conditional action row inside the
existing model dropdown; the regression test covers that `claude_local`
now receives the refresh action.

## Risks

- Low risk. Without `ANTHROPIC_API_KEY`, Claude Local still uses the
static fallback list.
- If Anthropic model discovery fails or times out, Paperclip falls back
to the existing cached or static list.
- Bedrock environments remain on Bedrock-native model IDs.

## Model Used

OpenAI GPT-5 via Codex local coding agent, with repository file access,
shell command execution, git operations, and targeted test/typecheck
verification. Exact context window is not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-29 07:03:07 -10:00
Devin Foley 1f70fd9a22 PAPA-430: workspace finalize gates + no-remote-git enforcement (#6969)
## Thinking Path

> - Paperclip orchestrates AI agents across isolated execution
workspaces; the local cwd is the only persistence boundary between runs.
> - Workspace lifecycle (worktree_prepare → execute →
workspace_finalize) and the wake/accept flow are what guarantee that
dependent issues see a consistent worktree.
> - PAPA-380 / PAPA-431 / PAPA-432 / PAPA-440 surfaced three holes in
that contract: silent env reuse across assignees, dependent wakes firing
before finalize, and `issue.interaction.accept` advancing before
finalize landed.
> - PAPA-441 / PAPA-442 then needed to document the "no remote git"
contract and prevent future adapter/runtime code from quietly
reintroducing `git push` as a backdoor sync.
> - This pull request lands those server fixes, the static
`check-no-git-push` enforcement, the AUTHORING.md cross-link, and the
Cody-review follow-ups on the PAPA-430 thread.
> - The benefit is that finalize is a real barrier — board accepts,
dependent wakes, and operator-set env all respect it — and adapter code
can't bypass it via raw `git push`.

## What Changed

- **server (PAPA-380, PAPA-431):** `execution-workspace-policy` refuses
silent env reuse when the assignee's resolved env disagrees with the
workspace it would inherit. The inheritance protection is now scoped to
the actual inheritance signal — explicit issue-level `environmentId` is
honored even when the agent's default env is `null`.
- **server (PAPA-432):** `heartbeat.ts` gates dependent wakes on
`listUnfinalizedExecutionWorkspaceIds`, and writes a
`workspace_finalize` row on the succeeded path. Write failures now
surface instead of being swallowed so dependents aren't silently
stranded behind a missing row.
- **server (PAPA-440):** `issue-thread-interactions.acceptInteraction`
adds a workspace_finalize precondition for `request_confirmation` (not
`suggest_tasks`). Accept returns 409 if finalize hasn't succeeded for
the latest workspace operation.
- **ci (PAPA-442):** new `scripts/check-no-git-push.mjs` static check
scans `packages/adapters/`, `packages/adapter-utils/`, `server/src/`,
and `cli/src/` for any `git push` invocation (string or args-array).
Wired into the `policy` PR job and `test:release-registry`. Operators
can opt in per-call with `// paperclip:allow-git-push: <reason>`.
Release scripts are out of scope by design.
- **docs (PAPA-441):** `AUTHORING.md` documents the no-remote-git
contract and cross-links the static check so adapter authors learn the
rule and the enforcement together.
- **review follow-up (PAPA-430, Cody):** three fixes — env resolver bug,
accept-gate scope (request_confirmation only), and finalize record write
on the succeeded path.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts` → 33/33
pass
- `node scripts/check-no-git-push.test.mjs` → check covers string form,
args-array form, comment exclusions, and per-line allow-comment.
- Manual: server compiles; the policy job runs the check in <1s before
heavier jobs.

## Risks

- **Behavioral shift in accept:** boards accepting
`request_confirmation` while finalize is in-flight now get 409s. This is
intentional — they can retry — but it changes timing on a hot path.
`suggest_tasks` is unaffected.
- **Workspace policy:** the env-reuse refusal is a new error path.
Issues that previously silently reused an env from a different-assignee
workspace will now fail-loud; the resolver still honors explicit
issue-level `executionWorkspaceSettings.environmentId`.
- **CI rule:** any future legitimate `git push` in scoped dirs must be
marked with the allow-comment, which is the intended ergonomic.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`, extended thinking), via Claude
Code in the Paperclip executor adapter.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server/CI/docs only)
- [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

Closes related issues: PAPA-430, PAPA-380, PAPA-431, PAPA-432, PAPA-440,
PAPA-441, PAPA-442

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 08:25:29 -07:00
Devin Foley 524e18b060 ci: use runner Chrome for headless workflows (#6967)
## Thinking Path

> - Paperclip relies on CI browser suites to protect control-plane
workflows, so a stalled browser bootstrap is a release blocker even when
app code is unchanged.
> - The failing signal on [PAPA-457](/PAP/issues/PAPA-457) was specific
to the PR e2e lane timing out before tests started, which pointed at
environment setup rather than assertions.
> - The first shell-only Chromium attempt reduced download size, but the
GitHub Actions log showed Playwright still hanging inside its install
step after the headless shell download finished.
> - That means the real problem is the Playwright browser-install path
itself on the hosted Ubuntu runner, not just the size of the downloaded
artifact.
> - GitHub's Ubuntu runners already ship Google Chrome, and Playwright
can target that binary through the `chrome` channel without downloading
its own Chromium bundle.
> - The safer workflow fix is therefore to remove the Playwright install
step from the affected headless jobs and make the Playwright configs
optionally use runner Chrome only when CI opts into it.
> - This keeps local defaults unchanged, removes the failing
browser-download dependency from CI, and preserves headless coverage for
PR, standalone e2e, and release-smoke workflows.

## What Changed

- Updated `.github/workflows/pr.yml`, `.github/workflows/e2e.yml`, and
`.github/workflows/release-smoke.yml` to stop downloading Playwright
browsers and instead verify the runner's preinstalled `google-chrome`.
- Passed `PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome` into the headless PR,
standalone e2e, and release-smoke test steps so those jobs explicitly
use runner Chrome.
- Updated `tests/e2e/playwright.config.ts` and
`tests/release-smoke/playwright.config.ts` to honor
`PAPERCLIP_PLAYWRIGHT_CHANNEL` while keeping the default
local/browser-bundle behavior unchanged when the env var is absent.

## Verification

- Investigated the failed PR run log and confirmed the prior `Install
Playwright` step stalled after `chromium-headless-shell` reached 100%
download.
- `PLAYWRIGHT_BROWSERS_PATH="$(mktemp -d)"
PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome PAPERCLIP_E2E_SKIP_LLM=true pnpm run
test:e2e`
Result: `7 passed (21.1s)` with an empty temporary Playwright browser
cache, proving the e2e suite runs without any Playwright browser
download when the `chrome` channel is selected.
- `git diff --check`

## Risks

- This assumes GitHub's Ubuntu runner continues to ship `google-chrome`;
if that image contract changes, these workflows would need a dedicated
Chrome install step.
- The `chrome` channel can differ slightly from Playwright-managed
Chromium, so the config gate is intentionally env-scoped to CI workflows
that need the hosted-runner path.

## Model Used

- OpenAI Codex, GPT-5-based coding agent running through Paperclip's
`codex_local` adapter with tool use, shell execution, and repository
editing enabled. The exact internal snapshot/version string is not
exposed in-session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [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
- [ ] 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-05-29 00:18:52 -07:00
Devin Foley d9f91576a0 Add accepted-plan decomposition exact-once guards and UI state (#6831)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, so
planning approvals and child-issue fan-out are part of the core
control-plane loop.
> - Accepted plans are supposed to be a safe bridge from planning into
execution, especially when agents wake from review decisions and reuse
isolated workspaces.
> - The duplicate-subtask incident showed that an accepted plan revision
could be interpreted more than once across overlapping runs, which broke
the single-source-of-truth model for issue decomposition.
> - Fixing that required tightening the backend contract first:
accepted-plan decomposition needs an exact-once fingerprint, durable
claim state, and retry-safe child creation.
> - Once that backend behavior existed, the board still needed
visibility into what happened, so the issue detail view needed a
dedicated decomposition section instead of forcing operators to
reconstruct child creation from raw activity.
> - This pull request adds the exact-once decomposition primitive,
hardens wake routing and regressions around the incident, and surfaces
decomposition state in the UI so future incidents are both prevented and
easier to inspect.

## What Changed

- Added accepted-plan decomposition semantics to
`doc/execution-semantics.md`, including the exact-once fingerprint,
durable claim/result expectations, and retry/resume behavior.
- Added persistent accepted-plan decomposition claims in the backend,
including schema, shared types/validators, service logic, and issue
routes for creating and listing decomposition state.
- Hardened heartbeat routing so an accepted-plan continuation stays
scoped to the relevant planning issue instead of opportunistically
re-decomposing another accepted issue on the same assignee.
- Added regression coverage for the original failure modes: concurrent
same-parent retries, cross-issue accepted-plan isolation, and partial
child recreation under the same fingerprint.
- Added the `Plan decomposition` issue-detail section plus supporting
API/query-key/activity formatting updates so operators can see revision
status, owner, child counts, and the linked child issues directly in the
UI.
- Included the small follow-up UI fix so the decomposition section still
renders when the issue work mode is no longer `planning`.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"lists persisted decompositions with child issue summaries"`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"accepted plan decomposition"
server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
server/src/__tests__/heartbeat-context-summary.test.ts`
- Manual UI path: create a planning issue without an isolated execution
workspace, add a `plan` document, accept the `request_confirmation`, let
Paperclip create child issues, then reopen the parent issue detail page
and confirm the `Plan decomposition` section shows the accepted
revision, status, idempotent-claim badge, and child links.
- Separate follow-up bug noted during manual UI validation: accepting a
plan on an issue whose run never records `workspace_finalize` is tracked
in `PAPA-445` and is not part of this PR’s fix scope.

## Risks

- This adds a new migration and a large Drizzle snapshot update;
reviewers should confirm the schema shape and generated metadata match
the intended decomposition table.
- The exact-once claim changes sit on the accepted-plan fan-out path, so
regressions there could block legitimate child creation or mis-handle
retries if the claim state machine is wrong.
- The new UI only appears when decomposition records exist; reviewers
should use the manual verification path above rather than expecting
existing issues on a stale local instance to show the section
automatically.
- `PAPA-445` remains an open follow-up for the `workspace_finalize`
accept gate when a planning handoff never records finalize; that bug can
interfere with reproducing the UI flow on isolated workspaces but does
not change the correctness of the exact-once decomposition feature
itself.

> Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening
change for accepted-plan decomposition, not a new uncoordinated roadmap
feature.

## Model Used

- OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent;
exact backend model ID/context window not exposed in the run context),
with repository tool use, shell execution, and code-editing
capabilities.

<img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05
48 PM"
src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28"
/>


## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-28 23:30:18 -07:00
Dotta 9eac727cf1 [codex] Add skills CLI and catalog management (#6782)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies through
company-scoped control-plane workflows.
> - Agents need reusable, inspectable skills that can be installed,
reset, audited, exported, and assigned without bespoke local setup.
> - The existing skill truth model needed cleanup so bundled skills,
optional catalog skills, runtime skills, and adapter-provided skills
have clear provenance.
> - Operators also need a practical CLI and board UI for discovering and
managing company skills.
> - This pull request adds the skills CLI, packaged skills catalog,
company skills APIs, and catalog-aware board UI.
> - The benefit is a more reusable Paperclip company setup where skills
are portable, auditable, and easier for operators and agents to manage.

## What Changed

- Added `paperclipai skills` CLI commands and coverage for catalog
listing, installing, resetting, and inspecting company skills.
- Added a packaged `@paperclipai/skills-catalog` workspace with bundled
and optional skill content plus validation/build tests.
- Added shared company-skill types and validators used across CLI,
server, and UI contracts.
- Added server catalog APIs/services for company skill catalog
operations, reset semantics, audit behavior, and portability provenance.
- Updated adapter skill handling so runtime/catalog provenance remains
explicit across local adapters.
- Added board UI support for browsing and managing catalog-backed
company skills.
- Updated docs for the skills CLI/catalog flow and the company skills
Paperclip skill reference.
- Rebased the branch onto current `paperclipai/paperclip:master`; no
`pnpm-lock.yaml`, `.github/workflows`, or migration files are included
in the final PR diff.

## Verification

- Passed: `pnpm run preflight:workspace-links && pnpm exec vitest run
cli/src/__tests__/skills.test.ts
packages/skills-catalog/src/catalog-builder.test.ts
packages/skills-catalog/src/shipped-catalog.test.ts
packages/shared/src/validators/company-skill.test.ts
packages/adapter-utils/src/server-utils.test.ts
packages/plugins/create-paperclip-plugin/src/entrypoints.test.ts
server/src/__tests__/company-skills-catalog-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/company-portability.test.ts`.
- Passed: `pnpm exec vitest run
server/src/__tests__/workspace-runtime.test.ts -t "default
branch|origin/master|symbolic-ref"`.
- Attempted: full `server/src/__tests__/workspace-runtime.test.ts`. Four
provisioning tests failed while seeding an isolated worktree database
from the local Paperclip instance because the local plugin schema dump
contains a duplicate-column foreign key
(`plugin_content_machine_18a7bc327b.content_case_signals`). The
default-branch tests touched by the rebase conflict passed in the
focused run above.
- Checked final diff: no `pnpm-lock.yaml`, no `.github/workflows`, and
no migration-file changes relative to `master`.

## Risks

- Medium: this is a broad skills/catalog change touching CLI, server
APIs, shared contracts, adapter skill sync, and UI.
- Catalog validation and reset semantics need careful reviewer attention
because they affect reusable company setup and portability.
- No database migrations are included in this PR, so there is no
migration ordering/idempotency risk in the final diff.
- No lockfile is included by design; dependency resolution will be
handled by the repository lockfile workflow.

## Model Used

- OpenAI Codex coding agent based on GPT-5, running in Paperclip via the
`codex_local` adapter with shell, git, GitHub CLI, and code-editing tool
access. Exact hosted model build/context-window metadata is not exposed
in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run targeted tests locally and documented the local
workspace-runtime seed failure above
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, screenshots were intentionally
omitted per PAP-10124 instructions; UI behavior is covered by tests and
reviewer inspection
- [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-05-28 07:33:51 -10:00
Dotta 8da50dbcf8 [codex] Add private browser first-admin claim flow (#6755)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Fresh self-hosted deployments need an operator path before any
invite exists.
> - Umbrel installs are private LAN deployments, so a one-time browser
claim is appropriate only when the deployment is private and unclaimed.
> - Public deployments and installs with active invites must keep the
existing invite-only model so admin creation is not exposed broadly.
> - GitHub PR #2927 established the useful direction, but it needed to
be adapted onto current `master` rather than merged as-is.
> - This pull request adds that adapted private-only claim flow across
server, UI, docs, and regression coverage.
> - The benefit is that a fresh private Umbrel-style install can be
claimed from the browser without weakening public deployment access.

## What Changed

- Added a first-admin claim service and access route support for
one-time admin claim eligibility on private unclaimed deployments.
- Updated the bootstrap/access UI so eligible private installs show a
setup claim path, while public and invited deployments keep invite-first
behavior.
- Added a bootstrap-pending setup UX lab covering claim, invite, public,
and signed-in access states.
- Updated deployment and local development docs for authenticated
private/public behavior and the Umbrel-style claim path.
- Added server and UI regression tests for private claim, public
no-claim, active invite fallback, existing board/no-access flows, and
health exposure reporting.
- Stabilized PR handoff verification by serializing the aggregate server
Vitest workspace run, forcing `NODE_ENV=test`, and relaxing the
heartbeat batching test around legitimate recovery follow-up runs.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `pnpm vitest --run
server/src/__tests__/heartbeat-comment-wake-batching.test.ts`
- `pnpm vitest --run
server/src/__tests__/health-dev-server-token.test.ts`
- `pnpm test:run`
- QA validation: PAP-10115 passed browser validation with screenshots
for private fresh install claim, active invite versus claim conflict,
public invite-only/claim-absent behavior, existing invite fallback, and
normal board/no-access flows.
- GitHub closeout: issue #2579 and PR #2927 were updated with the
accepted direction: adapt the implementation, do not direct-merge #2927
as-is.

## Risks

- The claim endpoint must remain private-only and one-time; a regression
here could expose admin creation on public deployments.
- Existing invite behavior must remain intact for public deployments and
installs that already have an active invite.
- The stable Vitest harness now serializes the aggregate server
workspace group; this is slower, but it avoids DB-backed suite
collisions under root workspace mode.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.
>
> ROADMAP.md checked: this is a scoped deployment bootstrap/access fix
and does not duplicate a listed roadmap project.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` for product
engineering, implementation, and verification, with tool-enabled local
code execution. Paperclip QA browser validation was performed in
PAP-10115 by the assigned QA agent; exact adapter model metadata for
that QA run is not exposed in this PR context.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-27 21:15:01 -10:00
Devin Foley de36743583 docs(readme): align README with brand guidelines (PAPA-439) (#6810)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The README is the first impression for developers and operators
landing on the repo, so it has to reflect the current brand voice and
visual identity
> - The existing README leads with an outdated hero ("Open-source
orchestration for zero-human companies"), keeps a board-centric tagline
that no longer matches the positioning, advertises a removed COMING SOON
teaser, and still uses an old header image and an unnecessary footer
image
> - Out-of-date positioning at the top of the README undercuts the rest
of the doc and the brand guidelines refresh at
https://paperclip.ing/brand
> - This pull request swaps the README header image for the new brand
banner, updates the hero copy and tagline, and trims stale callouts so
the README matches the new brand guidelines
> - The benefit is a README that leads with the current positioning
("Paperclip is the app people use to manage AI agents for work.") and
current visual identity, with no stale teasers or extraneous footer
image

## What Changed

- Added new brand banner at \`doc/assets/banner.jpg\` and pointed the
README header \`<img>\` at it (alt text updated to the new tagline)
- Replaced the \`## What is Paperclip?\` + \`# Open-source orchestration
for zero-human companies\` heading pair with a single H1: \`# Paperclip
is the app people use to manage AI agents for work.\`
- Tightened the opening paragraphs ("Open-source orchestration for teams
of AI agents.", trimmed dashboard sentence, "Under the hood:" line,
period on the OpenClaw/Paperclip tagline)
- Removed the \`COMING SOON: Clipmart\` callout
- Softened the Governance copy by dropping "You're the board." in both
the Features grid and the Systems table
- Fixed typo: "solo-entreprenuer" → "solo entrepreneur"
- Removed the README footer image block entirely
- Updated the closing subline: "Built for people who want to run
companies, not babysit agents." → "Built for people who want to get work
done, not babysit agents."
- Left existing assets untouched on disk: \`doc/assets/header.png\` and
\`doc/assets/footer.jpg\` are unchanged from master (only the README
references changed)

## Verification

- \`git diff master..HEAD --stat\` → only \`README.md\` (10+/18-) and
the new \`doc/assets/banner.jpg\`
- Rendered the README locally and confirmed:
  - The header banner shows the new brand image
- The H1 reads "Paperclip is the app people use to manage AI agents for
work."
  - No COMING SOON Clipmart callout
- No footer image; closing subline reads "Built for people who want to
get work done, not babysit agents."
- No code paths changed; no test suite applies

## Risks

- Low risk. Docs-only change. \`cli/README.md\` still references the
on-master URL for \`doc/assets/header.png\`, which is intentionally left
in place so that link does not break.

## Model Used

- Claude (Anthropic), model id \`claude-opus-4-7\` ("Opus 4.7"), running
under Claude Code via the Paperclip claude_local adapter.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass (n/a — docs-only)
- [x] I have added or updated tests where applicable (n/a — docs-only)
- [x] If this change affects the UI, I have included before/after
screenshots (n/a — README-only; rendered review described in
Verification)
- [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

Closes PAPA-439
2026-05-27 18:18:00 -07:00
Devin Foley a49afe5ea1 docs: update README license to Paperclip Labs, Inc (PAPA-437) (#6804)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The repository README closes with a license/copyright line that
downstream readers use to identify the legal entity behind the project
> - The line currently reads "MIT © 2026 Paperclip", which omits the
formal corporate name
> - The legal entity is "Paperclip Labs, Inc"; the README should reflect
that for accuracy
> - This pull request updates the README footer to "MIT © 2026 Paperclip
Labs, Inc"
> - The benefit is correct attribution of the MIT license to the actual
legal entity

## What Changed

- Updated `README.md` license line from "MIT © 2026 Paperclip" to "MIT ©
2026 Paperclip Labs, Inc"

## Verification

- Open `README.md` and confirm the final line reads `MIT © 2026
Paperclip Labs, Inc`
- No code paths affected; no tests required

## Risks

- Low risk — single-line documentation change, no runtime impact

## Model Used

- Provider: Anthropic Claude
- Model ID: claude-opus-4-7
- Capabilities: tool use, code execution via Claude Code CLI

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass (N/A — docs-only change)
- [x] I have added or updated tests where applicable (N/A)
- [x] If this change affects the UI, I have included before/after
screenshots (N/A — no UI change)
- [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
2026-05-27 15:07:02 -07:00
Dotta b7545823be [codex] Add document annotations and comments (#6733)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through issues, documents,
runs, and durable company-scoped state.
> - Issue documents are where agents and operators capture plans,
handoffs, and work products.
> - Before this change, document collaboration could only happen through
whole-document edits and detached issue comments.
> - Inline document annotations need stable anchors, revision-aware
persistence, and UI affordances that do not break existing document
editing.
> - This pull request adds company-scoped document annotation threads,
comments, anchor snapshots, API routes, and board UI.
> - The benefit is that operators and agents can discuss specific
document passages without losing context as documents evolve.

## What Changed

- Added document annotation tables, schema exports, shared types,
validators, anchor hashing, and text-anchor helpers.
- Added server-side document annotation services and issue routes for
listing, creating, commenting, resolving, and reopening annotation
threads.
- Included annotation summaries in relevant issue document reads and
backup/recovery document workspace behavior.
- Added React UI for inline document highlights, comment panels, mobile
sheet behavior, deep-link focus, and resolved/open filtering.
- Added annotation design artifacts, Storybook coverage, screenshots,
and a screenshot helper script.
- Rebased the branch onto current `paperclipai/paperclip` `master` and
renumbered the annotation migration from `0085_old_swarm` to
`0091_old_swarm`; the SQL uses `IF NOT EXISTS` guards so environments
that previously applied the old migration number can safely apply the
new one.
- Adjusted the new annotation UI tests to use a local async flush helper
because this workspace's React 19.2.4 export does not expose
`React.act`.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/document-anchors.test.ts
server/src/__tests__/document-annotation-routes.test.ts
server/src/__tests__/document-annotations-service.test.ts
ui/src/components/DocumentAnnotationLayer.test.tsx
ui/src/components/IssueDocumentAnnotations.test.tsx
ui/src/lib/document-annotation-hash.test.ts
ui/src/lib/document-annotation-selection.test.ts`
- Confirmed `git diff --check` passes.
- Confirmed no `pnpm-lock.yaml` or `.github/workflows/*` files are
included in the PR diff.

## Risks

- Medium risk: this adds new persisted annotation tables and routes
across db/shared/server/ui.
- Migration risk is reduced by moving the branch migration to
`0091_old_swarm` after upstream `0090_resource_memberships` and keeping
the SQL idempotent for old `0085_old_swarm` adopters.
- UI risk is mostly around text range anchoring and panel positioning
across long documents, folded content, and mobile layouts; the PR
includes focused unit coverage and design screenshots.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-using software engineering
mode. Context window size is not exposed in this Paperclip runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-26 06:41:23 -07:00
Dotta f0ddd24d61 [codex] Show bundled plugins in plugin manager (#6734)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system is how Paperclip exposes optional capabilities and
integrations without bloating the control plane.
> - Operators need the Instance Settings plugin manager to show both
installed external plugins and bundled built-in plugins.
> - Bundled plugins were available in the server/UI surface but were not
represented consistently in the plugin manager list.
> - Workspace runtime reuse also needed to stay pinned to the current
branch/base so the plugin manager can be validated from the intended
checkout.
> - This pull request shows bundled plugins in the manager, marks
experimental bundled plugins clearly, and tightens runtime/worktree
reuse guards.
> - The benefit is that operators can discover bundled plugins from the
same management screen as installed plugins without stale workspace
sessions hiding the latest branch state.

## What Changed

- Lists bundled monorepo plugin packages through the plugin routes API,
including plugin status and install metadata needed by the UI.
- Updates the plugin manager UI/API client to render bundled plugins and
display experimental badges based on installed plugin records.
- Adds server authorization coverage around plugin routes so board and
agent access stay company-scoped.
- Guards execution workspace/runtime reuse against stale base refs and
defaults new worktrees to the fetched target base.
- Expands workspace runtime tests for service reuse, stale workspace
prevention, and controlled runtime stops.
- Addressed Greptile feedback by respecting `origin/HEAD`, using async
cached bundled-plugin discovery, and avoiding duplicated UI experimental
plugin lists.

## Verification

- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime.test.ts
server/src/__tests__/heartbeat-workspace-session.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/plugin-sdk build && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `gh pr checks 6734 --repo paperclipai/paperclip` reports all checks
passing on `10e1ba9e0f505637cd913713fb28c2c99ae92011`.
- Greptile Review reports 5/5 on
`10e1ba9e0f505637cd913713fb28c2c99ae92011`.
- Confirmed the branch is rebased onto `public-gh/master` and the PR
diff does not include `pnpm-lock.yaml` or `.github/workflows` changes.
- UI screenshots were not captured in this PR-creation pass because the
available local board runtime is authenticated; the visible UI path is
covered by the plugin manager code changes and server/API tests above.

## Risks

- Medium risk: this touches shared plugin listing behavior and workspace
runtime reuse, so regressions could affect plugin manager visibility or
service reuse across execution workspaces.
- No database migrations.
- No lockfile or GitHub workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex, coding-agent workflow with shell/tool use in a
local Paperclip worktree. Context window not surfaced by the runtime;
reasoning mode not externally reported.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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-05-26 07:32:45 -06:00
Dotta 9aea3e3d35 [codex] Add resource membership controls (#6677)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through company-scoped
issues, projects, agents, and board-visible workflows.
> - The board sidebar and project list are the daily navigation surface
for that control plane.
> - Users need to keep all projects and agents accessible while hiding
resources they have intentionally left from their own sidebar.
> - That requires user-scoped resource membership state backed by
company-scoped API and database contracts.
> - The branch also needed to preserve HTTP worktree login sessions and
keep the project list easier to scan after membership grouping.
> - This pull request adds resource membership controls, sidebar leave
actions, grouped/sortable project listings, and focused tests.
> - The benefit is a cleaner personal workspace view without weakening
company-scoped access to the underlying project or agent detail pages.

## What Changed

- Added `project_memberships` and `agent_memberships` tables with
API/shared/server contracts for current-user join/leave state.
- Renumbered the membership migration to `0090_resource_memberships`
after rebasing onto current `master`, and made it idempotent for anyone
who had applied the old branch-local `0087` migration.
- Added project and agent sidebar leave actions, plus list filtering
that waits for membership state before hiding resources.
- Added grouped project listing, project sorting controls, and reserved
row subtitle height for cleaner scanning.
- Fixed HTTP auth cookie security handling so HTTP worktree sessions can
persist.
- Updated focused server and UI tests for the new membership, sidebar,
project list, and auth behavior.

## Verification

- `pnpm exec vitest run server/src/__tests__/better-auth.test.ts
server/src/__tests__/resource-memberships-routes.test.ts
ui/src/pages/Projects.test.tsx
ui/src/components/SidebarProjects.test.tsx
ui/src/components/SidebarAgents.test.tsx
ui/src/components/MembershipAction.test.tsx
ui/src/components/EntityRow.test.tsx`
- Confirmed the branch is rebased on current `origin/master`.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Migration safety: low to medium. The migration now uses `IF NOT
EXISTS` / guarded constraints and is numbered after current master
migrations, but it should still get CI coverage against fresh databases.
- UI behavior: low. Left resources are hidden from sidebar only after
membership state loads; direct detail access remains available.
- Auth behavior: low. Cookie security is relaxed only for HTTP/private
local-style origins where secure cookies would prevent login
persistence.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex coding agent, tool-enabled shell/git workflow,
context window not exposed by runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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

Screenshot note: no browser screenshots were captured in this heartbeat;
the UI changes are covered by focused component tests above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-25 13:12:41 -05:00
Dotta 60efa38f86 docs: add v2026.525.0 release changelog (#6667)
## Summary

- Adds `releases/v2026.525.0.md` for the upcoming stable release
covering 32 commits since `v2026.517.0` (2026-05-17 → 2026-05-25).
- Highlights: Modal sandbox provider plugin, first-party workspace diff
viewer, routine env secrets, local Cloud Upstream sync, and ACPX-Claude
adapter polish.
- Categorized into Highlights / Improvements / Fixes with inline PR +
contributor attribution per the release-changelog skill.

## Source range

`v2026.517.0..origin/master` (ECE8A51E22 at time of cut).

## Verification

- Manual scan of `git log v2026.517.0..HEAD --no-merges` and merged-PR
titles since 2026-05-17.
- Confirmed no `BREAKING CHANGE:` / `feat!:` markers in the range (no
Breaking Changes / Upgrade Guide sections needed).
- DB migrations in range (`0086_routine_env_runtime_contract`,
`0087_backfill_environment_manage_human_defaults`,
`0088_backfill_principal_access_compatibility`, `0089_cloud_upstreams`)
are additive/backfill-only — none flagged as breaking.
- Contributor list excludes Paperclip founders (cryppadotta, devinfoley)
and bot accounts per skill rules.

## Test plan

- [ ] Reviewer eyeballs the changelog against the recent PR list.
- [ ] Reviewer confirms the highlight set is the right shipped subset.

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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-25 09:12:23 -07:00
Dotta ece8a51e22 [codex] Bundle local branch fixes from PAP-10032 (#6604)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - This branch accumulated multiple already-tested control-plane,
adapter runtime, invite, workspace, plugin, and UI quality fixes on the
primary Paperclip checkout.
> - `origin/master` advanced while those commits were still local, so
the branch needed to be preserved and reconciled before review.
> - Splitting the branch commit-by-commit against the new base produced
overlapping conflicts with recently merged upstream PRs.
> - This pull request keeps the remaining branch as one standalone PR
because the final diff is 38 files after removing screenshot artifacts,
under Greptile's 100-file cap, and can be merged independently after
review.
> - The benefit is that none of the local work is lost, the branch is
now based on current `origin/master`, and reviewers can evaluate the
reconciled changes in one place.

## What Changed

- Merged the local accumulated branch with current `origin/master` and
resolved the invite-flow overlaps from the newer upstream companies
query helper.
- Preserved the local fixes for invite existing-member behavior, invite
link copy fallback, reusable workspace selection, worktree auth, static
SPA fallback, markdown wrapping, plugin slot registration, cloud
upstream UX/server polish, project sorting, and related tests.
- Removed screenshot artifacts from the PR per review request.
- Kept the PR under the requested file limit: 38 files changed, with no
`pnpm-lock.yaml` or `.github/workflows/*` changes.

## Verification

- `NODE_ENV=test pnpm exec vitest run
ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx
ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts
ui/src/components/MarkdownBody.test.tsx
server/src/__tests__/invite-accept-existing-member.test.ts
server/src/__tests__/static-index-html.test.ts
server/src/__tests__/execution-workspaces-service.test.ts
server/src/__tests__/better-auth.test.ts
server/src/__tests__/worktree-config.test.ts`
- `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck`
- `NODE_ENV=test pnpm --filter @paperclipai/server typecheck`
- Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`.
- Confirmed no PR diff entries match `pnpm-lock.yaml`,
`.github/workflows/*`, or `screenshots/*`.

## Risks

- Medium review risk because this is a bundled rescue PR rather than
several narrow feature PRs.
- Invite flow and company cache behavior overlapped with newer upstream
changes; the merge resolution intentionally keeps the shared
`companiesListQueryOptions` helper while preserving local
existing-member invite behavior.
- Visual review evidence is no longer attached in-repo because
screenshots were removed from this PR per review request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, with repository tool access,
terminal execution, and git/GitHub CLI operations.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] UI screenshots were intentionally removed from this PR per review
request
- [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>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: CodexCoder <codexcoder@paperclip.local>
2026-05-25 07:25:26 -05:00
Devin Foley 96f0279e08 Make ACPX-Claude adapter work seamlessly (PAPA-388) (#6590)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, so when
an adapter fails, the platform must surface enough detail for the next
agent (or human reviewer) to act
> - The `acpx_local` adapter wraps `claude-agent-acp`, which in turn
drives the Claude Code SDK — three layers, three different permission
and error-handling models
> - A user created a `Claude Local ACPX` agent in PAPA-387 and it failed
instantly with the generic `acpx.error / "Internal error"` log,
stranding the work and triggering an opaque `stranded_assigned_issue`
recovery to the CTO
> - Once the diagnostic blackbox was opened, the underlying cause turned
out to be two SDK-level mismatches: a model-name allowlist that rejects
bare IDs like `claude-opus-4-7`, and a Claude Code
permission/Read-sandbox configuration that silently denies every
non-allowlisted tool when the user's `~/.claude/settings.json` has
`defaultMode: "dontAsk"`
> - This pull request fixes both classes of failure in the adapter
itself so new ACPX agents work seamlessly without per-host
configuration, and widens the diagnostic surface so the *next* failure
of any kind is actionable
> - The benefit is that ACPX-Claude can join the regular agent roster —
verified end to end on PAPA-401, where the agent successfully reached
the Paperclip API, opened a worktree, surveyed existing notification
PRs, and posted a structured plan

## What Changed

- Widen ACPX failure diagnostics
(`packages/adapters/acpx-local/src/server/execute.ts`):
- Capture `err.name`, ACP code, `cause.message`, retryable flag, and a
5-frame stack preview into `errorMeta`.
- Promote phase-specific error codes: `ensure_session →
acpx_session_init_failed`, `configure_session →
acpx_session_config_failed`, `turn → acpx_turn_failed`, plus mapping for
`ACP_BACKEND_MISSING` / `ACP_BACKEND_UNAVAILABLE`.
- Set `verbose: true` on the ACPX runtime so its session-event log flows
through `ctx.onLog`.
- Capture child-process stderr via a wrapper-script tee into
`<stateDir>/run-stderr/<runId>.log`, inline the tail into the
`acpx.error` payload as `childStderrTail`, and forward it through
`ctx.onLog("stderr", …)` so it lands in the heartbeat `stderrExcerpt`
column (existing redaction applies).
- Set the model via `ANTHROPIC_MODEL` env for the `claude` agent instead
of `set_config_option(model, …)`. The ACP server's `set_config_option`
handler validates against an internal allowlist and rejects bare IDs
like `claude-opus-4-7`. `ANTHROPIC_MODEL` is read during initialization
and bypasses that check.
- Seed `<worktree>/.claude/settings.local.json` before spawning
`claude-agent-acp` (the seamless-API fix). Since `claude-agent-acp`
hard-codes `settingSources: ["user", "project", "local"]` and "local"
has the highest precedence:
- Set `permissions.defaultMode: "default"`, but **only** if the user's
value is missing or `"dontAsk"` (the broken case). Other modes like
`acceptEdits`/`plan` are preserved.
- Pre-allow Paperclip's Bash surface (`Bash(curl:*)`, `Bash(env:*)`,
`Bash(<cwd>/scripts/paperclip-issue-update.sh:*)`,
`Bash(<cwd>/scripts/paperclip:*)`).
- Widen `permissions.additionalDirectories` to include `stateDir`,
`agentHome`, and the per-company instance root
(`~/.paperclip/instances/<id>/companies/<companyId>`). Scoped to this
company only — does not expose other tenants.
- Existing user entries are merged, not replaced. The resolved roots are
folded into the session fingerprint so warm-session handles invalidate
when they change.
- Sync the existing server-side integration test
(`server/src/__tests__/acpx-local-execute.test.ts`) to assert
`acpx_session_init_failed` instead of the now-removed
`acpx_protocol_error` for `ACP_SESSION_INIT_FAILED` (a follow-up to
commit 1).

## Verification

- `pnpm --filter "@paperclipai/adapter-acpx-local" run typecheck` —
passes.
- `pnpm vitest run` in `packages/adapters/acpx-local` — 35/35 pass,
includes 4 new tests covering the settings.local.json write path (claude
only, merge with pre-existing content, `dontAsk` override, codex no-op).
- `pnpm vitest run src/__tests__/acpx-local-execute.test.ts` in
`server/` — 15/15 pass after the test-sync commit.
- End-to-end manual verification (PAPA-401): the `Claude Local ACPX`
agent that previously hit "restricted environment" now successfully
reaches the Paperclip API, opens its worktree, posts structured plan
comments, and flips the issue to `in_review` without any external
configuration.

## Risks

- **Low**, scoped to the `acpx_local` adapter. The settings.local.json
write is per-worktree (worktrees live under
`.paperclip/worktrees/<issue>/`) and only triggers when `acpxAgent ===
"claude"`. Existing user content is merged with `[...existing,
...paperclip]` and deduped — nothing is overwritten outright.
- The `defaultMode` override is intentionally narrow: it only flips
`"dontAsk"` (which silently denies every tool and is the root cause) to
`"default"`. Users who explicitly picked `acceptEdits`, `plan`, or any
other mode keep their choice.
- Stderr capture goes through the existing `log-redaction` pass before
persisting, so `PAPERCLIP_API_KEY` and similar secrets in the wrapper
env don't leak into heartbeat logs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), running in the `claude_local`
adapter via Paperclip's harness. Extended thinking enabled, tool use
enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A (adapter-only)
- [ ] I have updated relevant documentation to reflect my changes — no
user-facing docs changed; internal commentary in the code change
explains the SDK constraints
- [x] I have considered and documented any risks above
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-23 13:01:27 -07:00
Aron Prins 897cc322c7 Improve external agent invite flow (#6183)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent creation can happen through local runtimes, managed runtimes,
and external agents that onboard through invites.
> - The old OpenClaw-oriented invite UX lived under company
settings/invites and made a gateway-specific path look like a company
access setting.
> - That hid the broader bring-your-own-agent flow and forced operators
to leave the add-agent modal when adding an external agent.
> - This pull request moves external agent invite generation into the
add-agent modal and makes the copy agent-oriented instead of
OpenClaw-only.
> - The benefit is a clearer agent-first onboarding path while company
invites stay focused on human access.

## What Changed

- Added an external-agent invite branch to the add-agent modal,
including a dedicated prompt result view with Back navigation.
- Added a shared agent onboarding prompt builder and focused modal
coverage for prompt replacement/back navigation.
- Removed the agent invite prompt UI from Company Settings and Company
Invites, leaving Company Invites focused on human access links and
invite history.
- Updated the hidden OpenClaw Gateway runtime hint to direct operators
to the add-agent invite flow instead of presenting it as a blocked
runtime card.
- Updated invite/onboarding docs, storybook coverage, and server-side
onboarding copy toward generic agent language while preserving existing
gateway compatibility.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `FAKE_BIN="$(mktemp -d)/bin"; mkdir -p "$FAKE_BIN"; printf
'#!/bin/sh\nexit 1\n' > "$FAKE_BIN/tailscale"; chmod +x
"$FAKE_BIN/tailscale"; PATH="$FAKE_BIN:$PATH" pnpm test:run`
- `pnpm test:run` without the fake `tailscale` shim was also attempted;
it failed only in two pre-existing CLI tailnet fallback tests because
this host has a real Tailscale address (`100.125.202.3`) where those
tests expect no Tailscale.
- Focused confirmation for that host-env issue: `FAKE_BIN=...
PATH="$FAKE_BIN:$PATH" pnpm exec vitest run --project paperclipai
cli/src/__tests__/network-bind.test.ts
cli/src/__tests__/onboard.test.ts`
- Manual UI verification: served UI locally in light mode, opened
add-agent modal, generated external agent prompt, verified the generated
prompt replaces the form and Back returns to the form.

### Screenshots

![Add agent
modal](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/add-agent-modal-light.png)

![External agent invite
form](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/external-agent-invite-form-light.png)

![Generated onboarding prompt replacement
view](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/onboarding-prompt-result-light.png)

## Risks

- Existing OpenClaw gateway compatibility remains, but operators now
discover external agent onboarding from the add-agent modal instead of
company settings.
- Agent invites still appear in the invite history table, so that page
may show agent-scoped invite rows even though it no longer creates agent
onboarding prompts.
- Low migration risk: no schema changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent in Codex desktop; tool-enabled
repository, shell, browser, and GitHub workflow. Context window size was
not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-23 09:09:40 -05:00
Devin Foley e3c875c1c7 fix(sandbox): prevent E2B workspace upload + lease idle failures (PAPA-382) (#6560)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Heartbeats run inside managed sandboxes (E2B, Cloudflare Sandbox),
and each run begins by uploading the agent's workspace as a tar archive
> - PAPA-381's E2B runs were failing at 5 and 11 minutes — two distinct
failure modes were entangled: workspace tar extraction errors on Linux,
and sandbox idle/lease timeouts during normal heartbeat gaps
> - Workspace tar extraction failed because macOS bsdtar embeds
`LIBARCHIVE.xattr.*` PAX headers that GNU tar on Linux rejects with
"This does not look like a tar archive"; the existing
`COPYFILE_DISABLE=1` only suppresses AppleDouble `._*` sidecars, not
inline PAX xattr entries
> - E2B sandboxes also expired between heartbeats because `timeoutMs`
defaulted to a short window and was never refreshed per execute, and
Cloudflare sandboxes idled out because `sleepAfter` defaulted to 10
minutes
> - This pull request adds `--no-xattrs` to the workspace tar
invocation, refreshes the E2B sandbox lifetime on each execute and bumps
the default `timeoutMs` to 1h, and raises the Cloudflare `sleepAfter`
default to 1h
> - The benefit is that long-running heartbeat-driven runs (Claude,
Codex, etc.) survive across both their initial workspace upload and the
natural idle gaps between executes on both E2B and Cloudflare

## What Changed

- `packages/adapter-utils/src/sandbox-managed-runtime.ts`: added
`--no-xattrs` to `createTarballFromDirectory` so macOS bsdtar produces a
clean POSIX tar that GNU tar on Linux can extract, with an inline
comment explaining why `COPYFILE_DISABLE=1` alone is insufficient.
- `packages/plugins/sandbox-providers/e2b/src/plugin.ts`: refresh the
sandbox lifetime on every execute (so long runs don't expire mid-job)
and raised the default `timeoutMs` to 1h.
- `packages/plugins/sandbox-providers/e2b/src/manifest.ts` and
`plugin.test.ts`: updated manifest defaults and added regression
coverage for the new behavior.
- `packages/plugins/sandbox-providers/cloudflare/src/config.ts`,
`manifest.ts`, `plugin.test.ts`: raised default `sleepAfter` from 10m to
1h, mirroring the E2B 1h default, and added a regression test asserting
the acquire-lease request body sends `sleepAfter: "1h"` when not
overridden.

## Verification

- `pnpm --filter @paperclipai/plugin-e2b test`
- `pnpm --filter @paperclipai/plugin-cloudflare-sandbox test`
- Locally cherry-picked the `--no-xattrs` fix onto master and confirmed
end-to-end via a real PAPA-381-style heartbeat-driven E2B run that the
workspace upload now extracts cleanly on Linux. The user (board
operator) tested this on master and reported "Ok, that worked."
- Manual reviewer steps: trigger an E2B heartbeat from a macOS host
(this is where the bsdtar xattr headers come from), confirm the
workspace tar extracts on the Linux sandbox side; run a long (>15 min)
Cloudflare sandbox flow and confirm no lost-lease/idle errors between
executes.

## Risks

- Low risk overall.
- `--no-xattrs` is widely supported by both macOS bsdtar and GNU tar
(Linux). Worst case it silently no-ops on a future host that doesn't
support it; in that case the existing failure mode reappears, it doesn't
introduce a new one.
- Raising default `timeoutMs` (E2B) and `sleepAfter` (Cloudflare) from
short values to 1h means sandboxes stay alive longer between executes by
default. This is the intended behavior — operators that want a tighter
idle window can still override via plugin config.
- E2B per-execute sandbox lifetime refresh adds a small API call per
execute; it is bounded by the same client that already handles execute
traffic, so no new dependencies or retry semantics.

## Model Used

- Claude (Anthropic), `claude-opus-4-7`, extended thinking enabled, tool
use enabled (file/grep/git tools and Paperclip control-plane API). Used
to diagnose the dual failure mode (workspace tar PAX xattr headers +
sandbox lifetime), write the fixes and tests, and drive the verification
loop with the board operator.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — no UI changes)
- [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-05-22 13:34:11 -07:00
Aron Prins e85ff094ec fix(ui): invite page goes blank from companies query-key collision (#6433)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies; humans
operate the board through the React UI.
> - The board gates company access via `CompanyProvider`
(CompanyContext) and onboards new humans through the invite landing page
at `/invite/:token`.
> - Reported symptom: opening an invite link and signing in works, but
the page then renders completely blank (black in dark mode).
> - End-to-end browser testing reproduced a client-side crash:
`companiesQuery.data?.some is not a function` and `Cannot read
properties of undefined (reading 'filter')`.
> - Root cause: `CompanyProvider` and `InviteLandingPage` both use the
React Query key `["companies"]` but return **different shapes** — `{
companies, unauthorized }` vs a bare `Company[]` — so they silently
corrupt the shared cache entry; whichever component reads the other's
shape calls `.some()`/`.filter()` on the wrong type and throws,
unmounting the tree.
> - Owners never hit it (they never mount the invite page); only
invitees landing on `/invite/:token` crash.
> - This PR unifies the `["companies"]` query into a single shared
definition so the cache entry always has one shape and the two consumers
can't drift apart again.
> - The benefit is a working invite/onboarding flow and removal of a
whole class of cache-shape bugs on this key.

## What Changed

- Add `ui/src/api/companies-query.ts` exporting a single shared
`companiesListQueryOptions` (and `CompanyListResult`) — one `queryKey` +
one `queryFn` that always returns the wrapped `{ companies, unauthorized
}` shape, documented with the shared-cache contract.
- `ui/src/context/CompanyContext.tsx` now uses
`useQuery(companiesListQueryOptions)` instead of an inline copy of that
query.
- `ui/src/pages/InviteLanding.tsx` uses the same
`companiesListQueryOptions` (with its own `enabled` gate), reads
`companiesQuery.data?.companies` for the membership checks, and uses
`queryClient.fetchQuery(companiesListQueryOptions)` in the post-auth
path — so it reads and writes the identical shape.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `vitest run src/pages/InviteLanding.test.tsx
src/context/CompanyContext.test.tsx` — 17/17 pass, unchanged.
- Manual end-to-end via a real browser against a LAN-exposed
authenticated instance:
  - Owner creates an Owner-role invite.
- New user opens the link and registers — **the "awaiting approval"
screen renders** (previously blank), `POST /api/invites/:token/accept`
returns `202`, no console errors.
- Owner approves at Company Settings → Access (`200`); invitee becomes
an active member.
- Invitee signs in — full board loads; smoke test of dashboard / issues
/ inbox / routines / goals / company settings — all render, zero
`pageerror`s.
- Before: invite page `#root` empty after sign-in (blank/black). After:
awaiting-approval panel renders. (Screenshots available on request.)

## Risks

- Low. `CompanyProvider`'s query behavior is unchanged (same `queryFn`
logic, just extracted into a shared module). `InviteLandingPage` now
reads the same shape it writes. No API, schema, or migration changes.
Existing tests pass unchanged.

## Model Used

- Claude (Anthropic), model ID `claude-opus-4-7` (Opus 4.7), 1M-context,
extended thinking + tool use; driving Claude Code with browser
automation for end-to-end reproduction and verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable (existing
InviteLanding/CompanyContext tests cover the touched code and pass; a
cross-provider regression test that mounts both consumers is a sensible
follow-up)
- [ ] If this change affects the UI, I have included before/after
screenshots (described textually above; this is a crash/blank-page fix,
screenshots available on request)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:28:49 -05:00
Aron Prins 4811d8dd33 Fix wrapped company issue prefix conflicts (#6423)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Company creation is the first control-plane object operators create,
and the generated issue prefix becomes part of task identity.
> - The company service already retries when a generated issue prefix
collides with the `companies_issue_prefix_idx` unique constraint.
> - Drizzle 0.45.x wraps PostgreSQL errors in `DrizzleQueryError`,
leaving the real `23505` constraint error on the `.cause` chain.
> - The existing retry detector only inspected the top-level error, so
wrapped prefix collisions surfaced as 500s instead of retrying.
> - This pull request walks the error cause chain for the exact prefix
constraint and verifies the retry path against embedded Postgres.
> - The benefit is company creation no longer fails when generated
prefixes collide under Drizzle 0.45.x wrappers.

## What Changed

- Walk the error `.cause` chain when detecting
`companies_issue_prefix_idx` unique violations, with a cycle guard and
support for `constraint` / `constraint_name` fields.
- Added an embedded Postgres regression test that seeds `ARO`, creates
`Aron & Sharon`, and verifies the retry produces `AROA`.
- Stabilized existing async tests touched by full verification: instance
sidebar plugin rendering now waits for React Query results, and
Tailscale-unavailable CLI tests explicitly hide host `tailscale`
detection.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/companies-service.test.ts`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-stale-queue-invalidation.test.ts`
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/InstanceSidebar.test.tsx`
- `pnpm --filter paperclipai exec vitest run
src/__tests__/network-bind.test.ts src/__tests__/onboard.test.ts`
- `pnpm test:run`
- `pnpm -r typecheck`
- `pnpm build`

## Risks

- Low runtime risk: the retry behavior only expands detection for the
existing exact company issue-prefix unique constraint.
- The cause-chain walk is bounded by visited objects to avoid cycles.
- The sidebar and CLI changes are test-only stabilization and do not
change production behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent in Codex desktop, with local
shell/tool execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A: no UI behavior change)
- [x] I have updated relevant documentation to reflect my changes (N/A:
bug fix with no user-facing docs change)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Closes #6350
2026-05-22 15:27:54 -05:00
Dotta 90117827eb [codex] Polish board UI mobile flows (#6550)
## Thinking Path

> - Paperclip is the board UI and control plane for supervising AI-agent
companies.
> - Operators repeatedly use mobile navigation, issue creation, inbox
scanning, and markdown reading surfaces.
> - Small layout and interaction rough edges add friction to those
high-frequency workflows.
> - The branch included a set of related board UI polish changes that
were too small to review as many separate PRs.
> - This pull request groups the remaining mobile/navigation/markdown
polish into one standalone branch.
> - The benefit is smoother board operation without mixing in unrelated
backend feature work.

## What Changed

- Tightened company settings navigation behavior on mobile.
- Fixed mobile new issue dialog height and moved issue priority into the
overflow controls on small screens.
- Restored browser controls for home-screen app mode.
- Fixed plugin-route sidebar selection on nested page loads.
- Added markdown preformatted-block wrapping controls and coverage.
- Kept updated issue list pages sorted by updated time in the board UI.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run ui/src/components/Layout.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/components/MarkdownBody.wrap.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx
ui/src/lib/pwa-install-mode.test.ts ui/src/pages/Inbox.test.tsx`

The targeted UI tests passed. React emitted existing act-wrapping
warnings in a few test files, but there were no test failures.

## Risks

- Medium-low: changes span several UI surfaces, but they are mostly
layout/interaction polish with targeted component tests.
- Visual screenshots are not newly captured in this split PR; follow-up
review should include browser/visual QA before marking ready.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-22 10:13:47 -05:00
Dotta ad6effa65c [codex] Improve runtime and import reliability (#6549)
## Thinking Path

> - Paperclip coordinates autonomous company work through local and
hosted runtime surfaces.
> - Local embedded Postgres and tenant import/export paths are
foundational reliability pieces.
> - A runtime failure in either path can stop agents or imports before
useful work begins.
> - The branch included remaining fixes for embedded native library
bootstrap and async tenant import handling.
> - This pull request groups those runtime/import reliability changes
into one standalone PR.
> - The benefit is a more robust local runtime and safer cloud tenant
import behavior.

## What Changed

- Prepared embedded Postgres native runtime before startup in
CLI/server/test entrypoints.
- Added embedded Postgres native bootstrap coverage.
- Added async tenant import job handling and deferred validation
coverage.
- Kept the runtime/import changes based directly on current
`origin/master` after related upstream PRs had already merged.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/db/src/embedded-postgres-native.test.ts
server/src/__tests__/company-portability-routes.test.ts`

## Risks

- Medium-low: this touches startup/import paths, but the branch is small
and covered by targeted tests.
- The embedded Postgres change depends on platform-specific
native-library behavior, so CI and follow-up checks should still verify
supported runners.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-22 09:57:22 -05:00
Dotta e43b392a79 [codex] Add local Cloud Upstream sync (#6548)
## Thinking Path

> - Paperclip is the control plane for AI-agent companies.
> - Operators need a path to move local company state toward Paperclip
Cloud without losing local-first control.
> - The Cloud Upstream flow needs API, persistence, CLI, and board UI
surfaces that agree on the same manifest/run model.
> - The existing branch had the feature work plus UX and error-handling
follow-ups.
> - This pull request packages the remaining Cloud Upstream sync work
into one standalone branch.
> - The benefit is an inspectable local-to-cloud sync workflow with
preview, conflicts, activation, and captured UX review states.

## What Changed

- Added Cloud Upstream shared types, server routes/services, and
persisted run schema/migration.
- Added Paperclip Cloud CLI sync helpers and local connection storage.
- Added the Cloud Upstream board UI, settings entry points, query keys,
and UX lab page.
- Added preview/activation checklist behavior, redirect handling,
manifest-only preview support, friendly errors, in-flight hints, and
entity count summaries.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
ui/src/pages/CloudUpstream.test.tsx
ui/src/components/CompanySettingsSidebar.test.tsx`
- `NODE_ENV=test pnpm exec vitest run
server/src/__tests__/cloud-upstreams.test.ts`

Worktree setup note: the isolated worktree install skipped native sqlite
build scripts, so I copied the already-built local sqlite binding from
the main checkout before running
`server/src/__tests__/cloud-upstreams.test.ts`. The test then passed.

## Risks

- Medium: this adds a database migration and a broad feature path across
CLI/server/UI.
- Merge order: this is the only PR in this split with a DB migration;
merge it before any future Cloud Upstream migration follow-up.
- Mitigation: the PR is based directly on current `origin/master`, has
targeted route/service/UI tests, and keeps the feature behind existing
experimental Cloud Sync settings.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, screenshot artifacts are
intentionally omitted per reviewer request
- [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
2026-05-22 09:56:22 -05:00
Dotta a1835cfa5e [codex] Harden plugin runtime invocation scope (#6547)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a company-scoped
control plane.
> - Plugins extend that control plane, but plugin workers still call
back into host APIs.
> - Those worker-to-host calls need the same company boundary guarantees
as normal API routes.
> - Plugin action handlers also need authenticated actor context from
the host instead of trusting caller-supplied params.
> - This pull request hardens plugin bridge/action scope and keeps
plugin operation issues out of normal issue surfaces.
> - The benefit is safer plugin execution with clearer authorization
boundaries and better test coverage.

## What Changed

- Added host-owned invocation context plumbing for nested plugin worker
calls.
- Added actor context to plugin `performAction` calls and test harness
helpers.
- Enforced company invocation scope on worker-to-host calls and filtered
company lists to the active invocation scope.
- Extended plugin action route tests for board and agent actor context,
spoofed company params, and cross-company rejection.
- Extended plugin worker manager coverage for invocation-scope
propagation.
- Filtered typed and legacy plugin operation issue origins from default
issue/inbox lists.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/plugins/sdk/tests/host-client-factory.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/issues-service.test.ts`

Note: embedded Postgres issue-service tests reported host-level Postgres
init skip for 47 tests; the non-embedded targeted tests passed.

## Risks

- Medium: plugin host authorization paths are sensitive, and external
plugins may rely on previously loose company params.
- Mitigation: the change only tightens calls when the host attached a
company invocation scope and includes explicit tests for board, agent,
and nested worker calls.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-22 09:16:24 -05:00
Dotta 38c185fb8b [codex] Add agent permissions and controls plan (#6386)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.

## What Changed

- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.

## Verification

- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.

## Risks

- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-22 08:12:52 -05:00
Dotta c91a062326 [codex] Runtime control-plane fixes (#6380)
## Thinking Path

> - Paperclip orchestrates AI agents through a server-side control plane
> - That control plane depends on reliable issue state transitions,
plugin lifecycle behavior, import limits, and startup/shutdown handling
> - Several small runtime fixes had accumulated on the working branch
and were mixed with larger feature work
> - Keeping them separate makes the correctness fixes reviewable and
mergeable without waiting for cloud-sync UI work
> - This pull request groups the server/runtime control-plane fixes into
one standalone branch
> - The benefit is a tighter, safer runtime baseline for retries,
imports, plugin migrations, feedback flushing, and trusted cloud import
handling

## What Changed

- Fixed updated issue list pagination sorting and scheduled retry
comment handling.
- Re-applied pending plugin migrations during hot reload and fixed
plugin-schema worktree seed restore.
- Hardened public tenant DB startup, portable import body limits,
trusted cloud import errors, and trusted cloud tenant import mutation
access.
- Expired stale request confirmations after user comments.
- Added feedback export shutdown hardening so database-unavailable flush
loops stop cleanly.
- Guarded plugin worker `error` event emission when no listener is
registered.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `npm run install --prefix
node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/plugin-lifecycle-restart.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/body-limits.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/error-handler.test.ts
server/src/__tests__/board-mutation-guard.test.ts
packages/db/src/backup-lib.test.ts` initially exposed local setup issues
and two 5s test timeouts.
- Rerun after local prereq build: `pnpm exec vitest run --testTimeout
15000 server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` passed.
- Some embedded Postgres-backed tests skipped on this host because local
Postgres init was unavailable.

## Risks

- Runtime-touching branch: startup/shutdown and issue interaction
behavior should be reviewed carefully.
- The feedback export change disables repeated flush attempts only for
database connection-refused failures; other upload failures still log
normally.
- The plugin worker error guard avoids process crashes from unhandled
EventEmitter errors but may hide errors from code paths that expected an
emitted listener.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-20 10:37:11 -05:00
Dotta f257530537 [codex] UI and dev ops quality-of-life (#6384)
## Thinking Path

> - Paperclip operators spend most of their time scanning the board,
inbox, sidebar, and local dev status surfaces
> - Small UI and dev-ops frictions make repeated operator workflows feel
slower than they need to be
> - The working branch contained several independent quality-of-life
improvements mixed with larger cloud work
> - Grouping these smaller UI/dev-ops changes together keeps review
overhead reasonable without merging them into feature PRs
> - This pull request collects the operator-facing QoL polish into one
standalone branch
> - The benefit is a cleaner board navigation and local dev recovery
experience without depending on cloud upstream sync

## What Changed

- Relaxed forced 44px touch targets for small inline widgets.
- Fixed mobile mention menu scrolling and sidebar spacing on
touch/mobile layouts.
- Synced inbox hover state with j/k selection.
- Moved plugin sidebar entries into the Work section.
- Added manual dev-server restart action/banner behavior.
- Logged plugin bridge 502 causes for better diagnosis.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarProjects.test.tsx ui/src/pages/Inbox.test.tsx
ui/src/components/DevRestartBanner.test.tsx
server/src/__tests__/dev-server-status.test.ts
server/src/__tests__/health-dev-server-token.test.ts
server/src/__tests__/plugin-routes-authz.test.ts` initially failed only
because plugin SDK `dist` was not built in the fresh worktree.
- Rerun after build: `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts` passed.
- The remaining targeted UI/dev-server tests passed on the first
post-install run.

## Visual Evidence

- Sidebar layout and plugin Work section: ![Sidebar
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/sidebar-desktop.png)
- Inbox/task row selection and hover-state surface: ![Inbox rows
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png)
- Dev restart banner desktop: ![Dev restart banner
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png)
- Dev restart banner mobile: ![Dev restart banner
mobile](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png)

## Risks

- Mostly UI/dev ergonomics with low data risk.
- Sidebar and inbox changes touch frequently used navigation surfaces,
so visual review on desktop/mobile is still useful.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-19 15:52:39 -05:00
Dotta 43c5bb81b6 [codex] Workspace diff polish (#6383)
## Thinking Path

> - Paperclip gives operators a workspace diff plugin so they can
inspect agent changes before review
> - The diff view needs reliable base-ref defaults and controls that
stay usable while scrolling large diffs
> - The working branch mixed those plugin improvements with unrelated
server and cloud work
> - Keeping the workspace diff plugin changes isolated makes them easy
to test and review
> - This pull request polishes the workspace diff plugin controls,
base-ref behavior, and sticky headers
> - The benefit is a more predictable diff review surface for agent
workspaces

## What Changed

- Fixed workspace diff default base-ref resolution.
- Improved split/unified and working-tree/against-ref pane controls.
- Made workspace diff headers stay sticky while scrolling.
- Added a review screenshot at
`screenshots/PAP-9841-workspace-diff.png`.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm --filter @paperclipai/plugin-workspace-diff exec vitest run
tests/plugin.spec.ts`
- Result: 9 tests passed.

## Risks

- UI-only plugin branch with low data risk.
- The default base-ref inference should be reviewed against unusual
worktree/upstream combinations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-19 15:51:13 -05:00
Dotta d67347be77 [codex] Provider vault secrets UX (#6381)
## Thinking Path

> - Paperclip orchestrates AI agents that need scoped, auditable access
to secrets
> - Hosted and external deployments need provider vault configuration
without exposing secret values in Paperclip metadata
> - AWS Secrets Manager vault setup previously required too much manual
operator knowledge
> - Provider vault discovery and removal belong together as an
independent secrets-management improvement
> - This pull request adds AWS provider vault discovery/prefill plus
vault removal flows
> - The benefit is a safer operator path for configuring external secret
storage before higher-level cloud workflows depend on it

## What Changed

- Added shared validators/types for AWS provider vault discovery
payloads and safe provider metadata.
- Implemented AWS provider vault discovery preview on the server.
- Added provider vault removal service/route behavior.
- Added Secrets page UI for discovery prefill, removal messaging, and
related rendering coverage.
- Added Storybook provider-vault fixtures and captured screenshots for
the new UX states.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run packages/shared/src/validators/secret.test.ts
server/src/__tests__/aws-secrets-manager-provider.test.ts
server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts
ui/src/pages/Secrets.render.test.tsx`
- Result: 4 files passed, 1 embedded Postgres-backed file skipped on
this host because local Postgres init was unavailable.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Secrets.render.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- Storybook screenshot capture against `Product/Secrets` on
`http://127.0.0.1:60381/iframe.html?id=product-secrets--secrets-inventory&viewMode=story&globals=theme:dark`

## Screenshots

Provider vaults tab after this change:

![Provider vaults
tab](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/provider-vaults-tab.png)

AWS discovery candidate flow:

![AWS discovery candidate
flow](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/aws-discovery-candidates.png)

Provider vault removal confirmation:

![Provider vault removal
confirmation](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png)

## Risks

- Secret provider metadata handling must remain non-sensitive;
validators reject credential-bearing Vault URLs and sensitive AWS
discovery keys.
- AWS discovery depends on deployment credentials being configured
correctly outside Paperclip-managed company secrets.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-19 15:50:23 -05:00
Dotta 9c29394f4d [codex] Allow cloud tenant import mutations without browser origin (#6378)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Paperclip Cloud imports local company data into tenant Paperclip
stacks through trusted server-to-server calls.
> - Tenant imports authenticate as board actors with `source:
"cloud_tenant"` because they act on behalf of an authorized stack user.
> - The board mutation guard correctly protects browser session
mutations with trusted `Origin`/`Referer` checks.
> - But the guard treated trusted Cloud tenant calls like browser
session mutations, so server-to-server imports without a browser origin
failed with `403 Board mutation requires trusted browser origin`.
> - This pull request exempts trusted Cloud tenant actors from
browser-origin enforcement while preserving the session-backed browser
guard.
> - The benefit is that authorized Cloud imports can persist into tenant
Paperclip storage without weakening browser CSRF protections.

## What Changed

- Allow `req.actor.source === "cloud_tenant"` through
`boardMutationGuard` without requiring browser `Origin` or `Referer`
headers.
- Add a focused regression test for Cloud tenant POST mutations without
an origin.
- Preserve the existing session-backed rejection test for board
mutations that lack a trusted browser origin.

## Verification

- `pnpm exec vitest run
server/src/__tests__/board-mutation-guard.test.ts`
- Result: 10 tests passed.

## Risks

- Low risk: this only expands the existing non-browser exemption list to
trusted Cloud tenant actors that have already passed tenant-server-token
authentication.
- The browser-session path remains covered by the existing rejection
test, so missing-origin browser mutations still fail.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled local repository
editing and shell verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-19 14:25:58 -05:00
Dotta bfe6369ef5 Guard cheap recovery model usage (#6371)
## Thinking Path

> - Paperclip is the control plane that coordinates AI-agent work
through issues, heartbeats, comments, approvals, and auditable recovery
paths.
> - The affected subsystem is heartbeat/recovery orchestration,
especially the optional cheap model profile used for operational
recovery overhead.
> - Cheap recovery should repair status and liveness, but it must not
become the worker lane that writes deliverables, continues source work,
or propagates cheap execution hints into downstream retries.
> - The gap was that cheap-profile hints could follow recovery wake
contexts and assignment overrides farther than intended, making real
work eligible to run on the cheap model.
> - This pull request separates status-only cheap recovery from normal
source-work continuations, adds route guards for deliverable mutations
during cheap status-only runs, and documents the invariant.
> - The benefit is safer retry/recovery behavior: cheap runs can clean
up control-plane state, while any remaining source work resumes through
a normal/original model path.

## What Changed

- Added recovery model-profile work classes so status-only recovery
carries explicit guard context and normal-model continuations scrub
cheap hints.
- Updated heartbeat, productivity review, liveness continuation, and
recovery service wakeups to request cheap only for bounded status-only
recovery work.
- Blocked cheap status-only recovery runs from writing issue documents,
plans, attachments, work products, or assigning downstream work back to
`modelProfile: "cheap"`.
- Added/updated server tests for cheap profile propagation,
artifact/document guards, route authorization, retry scheduling, and
successful-run handoff behavior.
- Documented the recovery model-profile lane in
`doc/SPEC-implementation.md` and `doc/execution-semantics.md`.
- After rebasing onto current `public-gh/master`, stabilized the new
`InstanceSidebar` plugin-filter tests so the PR check lane stays green.

## Verification

- Local: `pnpm exec vitest run --config vitest.config.ts
src/services/recovery/model-profile-hint.test.ts
src/__tests__/issue-agent-mutation-ownership-routes.test.ts
src/__tests__/issue-document-restore-routes.test.ts` from `server/` - 3
files, 37 tests passed after final edits.
- Local: `pnpm exec vitest run --config vitest.config.ts
src/__tests__/heartbeat-process-recovery.test.ts` from `server/` - 44
tests passed after rerunning the cleanup-sensitive file alone.
- Local: `pnpm --filter @paperclipai/ui exec vitest run
src/components/InstanceSidebar.test.tsx` - 4 tests passed.
- Local: `pnpm --filter @paperclipai/server typecheck` - passed.
- Local: `pnpm --filter @paperclipai/ui typecheck` - passed.
- PR checks on latest head `6f8c3b1380f5bd872c6f49f6f7188ecf3bb6d263` -
all green, including `verify`, build, typecheck,
server/general/serialized tests, e2e, Snyk, and policy.
- Greptile: pass 3 returned Confidence Score 5/5 with zero unresolved
Greptile review threads.

## Risks

- Medium risk: recovery behavior is intentionally stricter, so any path
that incorrectly relies on cheap recovery to keep doing source work will
now need to hand back to a normal-model run.
- Low migration risk: no schema changes.
- No product UI changes; the UI file touched is a test-only
stabilization after rebasing onto current `master`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent, GPT-5 model family (`gpt-5`), tool use and
local code execution enabled; context window not exposed in this
environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A: no product UI changes)
- [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
2026-05-19 13:46:02 -05:00
Devin Foley 24748de421 fix(ui): hide sandbox-provider plugins from Instance Settings sidebar (#6341)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Plugins extend Paperclip with new capabilities; the Instance
Settings sidebar exposes per-plugin settings pages under a "Plugins"
group
> - Some plugins contribute only sandbox-provider drivers (E2B, exe.dev,
Modal). They have no per-plugin settings UI — `PluginSettings` already
redirects sandbox-provider-only plugins to the Environments page
> - As a result, listing them as their own sidebar rows produced
confusing entries that visually nested below the "Adapters" group and
only lead to a stub redirect — there was no value to the user
> - This pull request hides sandbox-provider-only plugins from the
Instance Settings sidebar, and reorders the indented plugin list so it
sits directly under the "Plugins" group it actually belongs to
> - The benefit is a cleaner sidebar that only surfaces plugins with
real per-plugin settings, and removes the visual mis-nesting under
Adapters

## What Changed

- `ui/src/components/InstanceSidebar.tsx`: filter out plugins whose only
contribution is `sandboxProviders` (hybrid plugins that contribute
sandbox providers *plus* something else still get a sidebar entry). Move
the indented plugin list so it renders between the "Plugins" row and the
"Adapters" row instead of after Adapters.
- `ui/src/components/InstanceSidebar.test.tsx`: new test file with 4
cases — sandbox-only plugins hidden, hybrid plugins shown, ordering
(plugin list appears under Plugins and before Adapters), and the
existing non-plugin sidebar items still render.

## Verification

- `pnpm -C ui vitest run src/components/InstanceSidebar.test.tsx` → 4/4
pass.
- `pnpm typecheck` clean on the changed files.
- Manual: visit `/instance/settings/plugins` — "E2B Sandbox Provider"
and "exe.dev Sandbox Provider" rows no longer appear in the sidebar;
remaining plugins are listed directly under the "Plugins" group, not
below Adapters.

**Before:** see the screenshot embedded in the linked issue (`PAPA-375`)
— sandbox-provider plugins show as sidebar rows visually nested under
"Adapters".

**After:** sandbox-provider-only rows are gone; plugin list sits
directly under the "Plugins" group. (A live runtime screenshot was not
captured for this PR because the local server requires an authenticated
browser session not currently available to the agent — happy to add one
on request.)

## Risks

- Low risk. Pure UI filter + reorder, scoped to `InstanceSidebar.tsx`.
No backend or plugin-loader changes. Hybrid plugins that legitimately
need a settings entry are explicitly preserved by the filter. Covered by
4 new unit tests.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), Anthropic, via Claude Code in a
Paperclip heartbeat. Standard tool-use mode (no extended thinking). 200K
context.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — before is in the linked issue; after pending live capture
(see Verification)
- [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

Closes PAPA-375.

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-19 09:06:24 -07:00
Devin Foley c0c5a8263d feat(ui): wire SecretBindingPicker into JsonSchemaForm secret-ref fields (#6339)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Plugin authors expose configuration via JSON schemas, including
secret fields marked `format: "secret-ref"`
> - At the same time, Paperclip already has a first-class secrets store,
and `SecretBindingPicker` is the canonical UI for binding to one of
those stored secrets
> - But `JsonSchemaForm`'s `SecretField` rendered only a plain password
input, so configuring an E2B (or Modal / Cloudflare / Daytona) sandbox
required leaving the form, copying a secret UUID, and pasting it back
> - This pull request wires `SecretBindingPicker` into `SecretField` so
every plugin secret-ref field gets the picker plus an optional raw-value
fallback
> - The benefit is that secret reuse becomes one click instead of a tab
switch, and the raw-paste path still works for one-off keys or long
SSH-style secrets

## What Changed

- `ui/src/components/JsonSchemaForm.tsx` `SecretField` now renders
`SecretBindingPicker` above the existing password/textarea input.
UUID-shaped values are treated as bound refs (no raw input shown).
Non-UUID values keep the password/textarea visible (auto-opened) for SSH
keys and other long secrets. Empty fields show the picker plus a small
"Or paste a raw value" toggle.
- Selecting a secret writes the secret UUID to the form value — the
server-side resolution in `server/src/services/environment-config.ts`
(`resolveConfigSecretRefsForRuntime` / `collectEnvironmentSecretRefs`)
is unchanged. The version selector on the picker is suppressed
(`allowVersionSelector={false}`) because plugin secret refs always
resolve at `"latest"`.
- `ui/src/components/JsonSchemaForm.test.tsx` mocks the picker (which
requires `CompanyContext` + `QueryClient` providers) and adds coverage
for: picker render, UUID-bound state hides the raw input, picker
selection writes the UUID through `onChange`, raw text keeps the
password fallback. The original multiline (SSH key) case still asserts a
textarea + no password input.

## Verification

- `pnpm --filter @paperclipai/ui test
src/components/JsonSchemaForm.test.tsx` → 4/4 passing
- `pnpm --filter @paperclipai/ui test src/pages/PluginSettings.test.tsx`
→ 5/5 passing (existing consumer of `JsonSchemaForm`)
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` → clean
- Manual: in the company Environments page, edit an environment with a
sandbox driver that exposes a `secret-ref` field (e.g., E2B `apiKey`).
The field should render the secret dropdown above the raw-value toggle;
selecting an active secret persists its UUID, and saving the form
continues to resolve the secret at runtime.

Before/after screenshots: deferred — change was validated by
[@devinfoley](https://github.com/devinfoley) on the main Paperclip
instance before this PR was opened. Happy to add screenshots if a
reviewer wants them.

## Risks

- Low risk. The change is additive in the SecretField: the raw-value
password/textarea path is preserved and auto-opens whenever the stored
value is not a UUID, so existing SSH-key entries and unsaved raw values
are untouched.
- The new heuristic is "if `value` is a UUID, treat it as a bound
secret". A user who somehow pasted a UUID as a literal value (not as a
secret ref) would now see it rendered as a bound (possibly missing)
secret in the picker. The previous UI already treated UUID values as
opaque secret refs at save time (server converts UUIDs straight
through), so the runtime behavior is unchanged.
- Picker pulls company secrets via the existing `secretsApi.list` query.
No new endpoints, no migrations.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities: tool use, extended reasoning
- Surfaced through: Claude Code via Paperclip heartbeat (issue PAPA-377)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — deferred; user validated locally before opening the PR.
Will add if requested.
- [x] I have updated relevant documentation to reflect my changes (no
docs needed — internal behavior of an existing form field)
- [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-05-18 21:17:41 -07:00
Devin Foley f343bae119 fix(ci): copy link-plugin-dev-sdk.mjs into Docker deps stage (#6338)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Releases ship via a Docker image built in the `build-and-push` CI
workflow
> - A recent change added `plugin-workspace-diff` to the pnpm workspace;
its `postinstall` hook calls `scripts/link-plugin-dev-sdk.mjs`
> - The Dockerfile's `deps` stage runs `pnpm install` before the full
repo is copied, so the script was missing and `pnpm install` failed with
`Cannot find module`
> - Sandbox-provider plugins have the same hook but are excluded from
the pnpm workspace, so they were unaffected — this was specific to
`plugin-workspace-diff`
> - This pull request copies `scripts/link-plugin-dev-sdk.mjs` into the
`deps` stage alongside the package.json files
> - The benefit is restoring the `build-and-push` CI workflow with a
minimal one-line change

## What Changed

- Add `COPY scripts/link-plugin-dev-sdk.mjs scripts/` to the
Dockerfile's `deps` stage so the `plugin-workspace-diff` postinstall
hook succeeds during `pnpm install`.

## Verification

- Reproduces the original failure on `master` by running `docker build
--target deps .` — fails at `pnpm install` with `Cannot find module
'/app/scripts/link-plugin-dev-sdk.mjs'`.
- With this patch, `docker build --target deps .` completes successfully
through the `deps` stage.
- CI `build-and-push` job (previously failing on
https://github.com/paperclipai/paperclip/actions/runs/26055610103/job/76602841176)
should now pass.

## Risks

- Low risk. One-line addition that copies a single script earlier in the
Docker build. No runtime behavior changes, no app code changes, no
schema changes.

## Model Used

- Claude (Anthropic), model ID `claude-opus-4-7`, extended thinking
enabled, 200K context. Used via Claude Code CLI with tool use (Bash,
Read, Edit, Grep) running inside the Paperclip agent harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-18 20:55:34 -07:00
Dotta a07e6cef7b [codex] Fix new issue autocomplete pointer selection (#6311)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Human operators create and edit issues through modal-heavy board UI
workflows.
> - The new-issue dialog embeds markdown editors that render
autocomplete menus through body-level portals.
> - Radix Dialog treats those portal clicks as outside-dialog pointer
events and prevents their default behavior.
> - That prevention made completion items hard or impossible to select
from inside the new-issue dialog.
> - This pull request marks the markdown editor floating autocomplete
menu as allowed dialog-external UI and extends the dialog
outside-pointer handler to preserve those interactions.
> - The benefit is that users can click/tap autocomplete completions
while keeping the existing modal behavior intact.

## What Changed

- Added a stable `data-paperclip-floating-ui` marker and explicit
pointer event handling to the markdown editor mention/autocomplete
portal.
- Updated the new issue dialog outside-pointer guard so editor
autocomplete portals are handled like Radix popover portals.
- Added regression coverage for markdown editor portal markup and new
issue dialog completion selection behavior.

## Verification

- `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx
ui/src/components/NewIssueDialog.test.tsx` passed: 2 files, 38 tests.
- Confirmed the branch is rebased onto current `public-gh/master` before
opening this PR.
- Confirmed the diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Low risk. The change is scoped to allowing pointer events from known
body-level UI portals while keeping other outside-dialog pointer events
under Radix Dialog control.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent with repository tool use and local
command execution. Exact hosted context window is not surfaced in this
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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

Screenshot note: this is an interaction/event-handling fix with no
visible UI change; verification is covered by the focused regression
tests above.

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-18 14:28:49 -05:00
Devin Foley 988689947a fix(release): publish modal plugin from ci (#6290)
## Thinking Path

> - Paperclip keeps its core release process declarative through
`scripts/release-package-manifest.json`, which decides which packages CI
is allowed to publish.
> - The Modal sandbox provider now exists as a first-party plugin
package under `packages/plugins/sandbox-providers/modal`.
> - The original Modal PR intentionally left `publishFromCi` disabled
until the package had been published and the registry bootstrap concern
was cleared.
> - The latest reviewer comment confirms that bootstrap step is
complete, so the remaining gap is only release automation configuration.
> - This pull request flips the Modal manifest entry to `publishFromCi:
true` so future CI-driven releases can publish
`@paperclipai/plugin-modal` the same way the other releasable packages
do.
> - The benefit is that Modal releases no longer require a manual
exception in the release pipeline.

## What Changed

- Updated the `@paperclipai/plugin-modal` entry in
`scripts/release-package-manifest.json` to set `publishFromCi` to
`true`.

## Verification

- Ran `node -e 'const
m=require("./scripts/release-package-manifest.json"); const
e=m.find(x=>x.name==="@paperclipai/plugin-modal");
if(!e||e.publishFromCi!==true){throw new Error("modal publishFromCi not
true")}; console.log(JSON.stringify(e))'`

## Risks

- Low risk. This only changes release-manifest metadata; the main
failure mode is CI attempting to publish the Modal package before
registry credentials or release conditions are ready.

## Model Used

- OpenAI Codex local agent, GPT-5-based coding model in the Codex
runtime (exact deployment model ID not exposed in this workspace), with
tool use and shell execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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
2026-05-18 09:42:01 -07:00
Devin Foley e5a0f5debd fix(plugin): raise environmentProbe RPC timeout to 120s for cold-start sandboxes (#6289)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Companies provision execution environments via sandbox provider
plugins (Modal, Daytona, E2B, etc.)
> - At provision time, the server probes each plugin's environment /
sandbox-provider driver over a worker RPC to validate config
> - `workerManager.call()` defaults to a 30s timeout, but cold-start
sandboxes — Modal in particular — take ~31s to boot
> - Result: every fresh Modal environment probe fails with a worker RPC
timeout, blocking environment provisioning end-to-end
> - This PR passes `timeoutMs=120_000` to the two probe call sites
(`probePluginEnvironmentDriver`, `probePluginSandboxProviderDriver`)
> - The benefit is Modal — and any future provider with similar
cold-start latency — can be successfully probed without false-negative
timeout failures

## What Changed

- Pass `timeoutMs=120_000` to `workerManager.call()` in
`probePluginEnvironmentDriver`
(`server/src/services/plugin-environment-driver.ts`)
- Pass `timeoutMs=120_000` to `workerManager.call()` in
`probePluginSandboxProviderDriver` (same file)

## Verification

- Targeted unit tests:
  ```
  pnpm --filter @paperclipai/server exec vitest run \
    src/__tests__/plugin-environment-driver-seam.test.ts \
    src/__tests__/heartbeat-plugin-environment.test.ts
  ```
  5/5 tests pass.
- Manual: provision a fresh Modal sandbox environment from the UI.
Previously failed with a worker RPC timeout at ~30s; now succeeds.

## Risks

- Low risk. The change only raises a per-call timeout (default 30s →
explicit 120s) on two probe call sites. Fast providers are unaffected
since probe completes well below either bound. Worst case: a genuinely
hung worker now blocks the probe for 120s instead of 30s before giving
up — still bounded, and only on the provision-time probe path (not the
heartbeat/run path).

## Model Used

- Provider: Anthropic
- Model: `claude-opus-4-7` (Claude Opus 4.7, 1M context window)
- Capabilities: extended thinking, tool use, code execution
- Scope of AI assistance: the underlying 4-line code change was
human-authored by the committer; this PR (verification commands, message
structuring, and submission) was prepared with Claude per the
`paperclip-dev` skill.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable — n/a, this is a
per-call timeout configuration bump; existing tests cover the probe call
path
- [x] If this change affects the UI, I have included before/after
screenshots — n/a, no UI change
- [ ] I have updated relevant documentation to reflect my changes — n/a,
the timeout is an internal worker-RPC tuning value with no documented
contract
- [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-05-18 09:32:12 -07:00
Devin Foley 4b1e92a588 feat(plugins): add Modal sandbox provider plugin (#6245)
## Thinking Path

> - Paperclip orchestrates AI agents through company-scoped
control-plane workflows and extensible runtime integrations.
> - Sandbox providers are part of that extension surface because they
let agents execute isolated work without baking each provider into the
core server.
> - Modal already offers managed sandboxes with filesystem, process,
timeout, and networking controls that map onto Paperclip's sandbox
provider contract.
> - The repo did not have a Modal provider plugin, so teams wanting
Modal-backed sandboxes had no first-party integration path.
> - This pull request adds a standalone
`packages/plugins/sandbox-providers/modal` plugin that implements the
provider contract, worker entrypoint, docs, and tests.
> - The benefit is that Modal can now be installed as a provider plugin
without expanding the core control-plane surface area.

## What Changed

- Added a new `packages/plugins/sandbox-providers/modal` package with
the plugin manifest, worker entrypoint, and exported plugin surface.
- Implemented Modal-backed sandbox lifecycle support for creation,
command execution, file operations, networking options, termination, and
metadata translation.
- Added focused Vitest coverage for config validation, env handling,
lifecycle flows, networking behavior, and error mapping.
- Documented installation, configuration, and usage requirements in the
plugin README.
- Removed misleading `MODAL_TOKEN_*` fallback behavior so authentication
relies on supported Modal credentials only.

## Verification

- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`
- `cd packages/plugins/sandbox-providers/modal && pnpm test`

## Risks

- Low to medium risk: this is isolated to a new plugin package, but
runtime behavior still depends on live Modal account credentials and
service-side sandbox semantics.
- Modal's current docs target a newer Node baseline than the repo
default, so the first live install should confirm credential loading and
sandbox startup behavior in a real Modal workspace.
- No UI or schema changes are included in this PR.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex via Paperclip `codex_local` agent (GPT-5-class Codex
coding model; exact backend model ID is not exposed by the runtime),
with tool use, shell execution, and code-editing capabilities enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-18 08:36:34 -07:00
Dotta 8f45d12447 docs: update plugin authoring guide for managed resources (#6261)
## 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 5-8 steps. See CONTRIBUTING.md for full examples.
-->

> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system is how optional capabilities extend the control
plane without adding hidden core behavior.
> - Plugin authors need accurate guidance for the current managed
capabilities model.
> - The existing docs under-described managed skills and the
routine-first pattern for durable plugin automation.
> - Content-oriented plugins such as LLM Wiki should model recurring
work with visible managed agents, projects, routines, and skills.
> - This pull request aligns the authoring guide, local development
guide, and longer plugin spec with that model.
> - The benefit is clearer plugin guidance that preserves Paperclip
visibility, budgets, pause controls, and audit trails.

## What Changed

<!-- Bullet list of concrete changes. One bullet per logical unit. -->

- Documented plugin-managed skills alongside managed agents, projects,
and routines.
- Added guidance for content-oriented plugins to use managed projects,
agents, skills, and routines instead of private daemon-like state.
- Updated the manifest/spec examples and capability lists for current
plugin-managed surfaces.
- Clarified when to use managed routines instead of plugin runtime jobs
for board-visible recurring work.
- Added a short local plugin development note pointing authors toward
routine-first automation.
- Addressed Greptile docs feedback by marking top-level `launchers` as
legacy and removing a redundant `slug` from the managed skill example.

## Verification

<!--
  How can a reviewer confirm this works? Include test commands, manual
  steps, or both. For UI changes, include before/after screenshots.
-->

- `git diff --check public-gh/master...HEAD`
- Reviewed `ROADMAP.md`; this is docs alignment for the completed plugin
system milestone and does not add roadmap-level core feature work.
- Greptile Review: success on the latest head; `3 files reviewed, 0
comments added` after follow-up fixes.
- GitHub PR checks are green on the latest head, including Build,
Typecheck + Release Registry, General tests, serialized server suites,
e2e, Canary Dry Run, policy, Snyk, and aggregate `verify`.

## Risks

<!--
  What could go wrong? Mention migration safety, breaking changes,
  behavioral shifts, or "Low risk" if genuinely minor.
-->

- Low risk: documentation-only changes.
- Main risk is documentation drift if the plugin API changes again
before these docs are reviewed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.

## 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".
-->

- OpenAI Codex, GPT-5 coding agent with shell and GitHub connector tool
use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-18 10:14:27 -05:00
Dotta 32605b71ad Remove planning badge from inbox issue rows (PAP-9691) (#6269)
## Summary

- Removes the amber "Planning" pill from inbox / issue-list rows in
`IssueRow`
- Updates the focused IssueRow test to assert the badge is no longer
rendered
- Per [PAP-9691](https://paperclip.ing/PAP/issues/PAP-9691): user just
doesn't want to see the badge in list rows

The underlying `issue.workMode === "planning"` data, the issue detail
composer toggle, and the server/plugin/heartbeat work-mode contract
introduced in #5353 are all untouched. Planning mode still functions;
the list-row indicator is just gone.

## Test plan

- [x] `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueRow.test.tsx` (11 passed)
- [ ] Visual: open `/PAP/inbox` with a planning-mode issue assigned and
confirm no amber Planning pill on the row

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:27:05 -05:00
github-actions[bot] 85510f0e5b chore(lockfile): refresh pnpm-lock.yaml (#6263)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-05-18 08:52:09 -05:00
Dotta 5071c4c776 [codex] Add workspace diff viewer plugin (#6071)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators need to inspect what agents changed inside execution and
project workspaces.
> - The existing workspace detail views did not provide a first-party
rich diff surface for staged, unstaged, head, renamed, binary,
oversized, and untracked changes.
> - The plugin system is the intended extension point for optional rich
UI surfaces.
> - This pull request adds a workspace diff plugin plus host services
and shared contracts so Changes tabs can render workspace diffs through
plugin slots.
> - The diff-renderer dependency should stay owned by the plugin package
rather than the core UI app.
> - The dependency surface must stay aligned with repository PR policy,
including intentionally omitting `pnpm-lock.yaml` from the PR.
> - The benefit is a more reviewable workspace surface without
hard-coding the renderer into every page.

## What Changed

- Added `@paperclipai/plugin-workspace-diff`, including diff
normalization, plugin manifest/worker/UI entrypoints, and focused plugin
tests.
- Kept `@pierre/diffs` scoped to `@paperclipai/plugin-workspace-diff`;
removed the core UI lab diff-renderer surface and direct UI package
dependency.
- Added shared workspace diff types and validators, plus plugin SDK
surface for workspace diff host services.
- Added server workspace diff service support and route coverage for
execution/project workspace diff flows.
- Wired Execution Workspace and Project Workspace Changes tabs to load
the diff plugin, including loading/error fallback behavior.
- Added UI tests and fixtures for the Changes tabs and plugin bridge
behavior.
- Added the new plugin package manifest to the Docker deps stage so PR
policy can validate dependency coverage.
- Addressed review hardening around empty untracked patches, workspace
path exposure, project workspace read capability checks, and default
base refs.

## Verification

- `pnpm --filter @paperclipai/plugin-workspace-diff test`
- `pnpm exec vitest run
packages/shared/src/validators/workspace-diff.test.ts
server/src/__tests__/workspace-diff-service.test.ts
ui/src/pages/ProjectWorkspaceDetail.test.tsx
ui/src/pages/ExecutionWorkspaceDetail.test.tsx`
- `pnpm exec vitest run ui/src/plugins/bridge.test.ts
server/src/__tests__/workspace-runtime-routes-authz.test.ts`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/plugin-workspace-diff typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `node ./scripts/check-docker-deps-stage.mjs`
- Browser screenshot captured from the local worktree dev server:
https://files.catbox.moe/ofdpsp.png
- Confirmed branch is rebased onto `public-gh/master`,
`.github/workflows/pr.yml` is not included in the PR diff,
`ui/package.json` is not included in the PR diff, and `pnpm-lock.yaml`
is not included in the PR diff.

## Risks

- Medium UI integration risk: the Changes tab depends on the plugin slot
and host diff service path.
- Medium dependency risk: this adds `@pierre/diffs` in the plugin
package, but `pnpm-lock.yaml` is intentionally omitted per packaging
instructions because repository automation manages lockfile updates.
- Current CI blocker: downstream frozen installs fail until the
repository policy path for new plugin package dependencies is chosen.
- Diff rendering edge cases are covered for common working-tree and head
diff states, but very large repositories may still expose performance
limits.
- No migrations are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 class coding model, tool-enabled local execution
environment. Exact context window was not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-18 08:50:06 -05:00
Devin Foley 242a2c2f2b fix(cli): stop worktree init --force from wiping repo worktrees/ (#6240)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each working tree gets an isolated Paperclip instance; the CLI's
`paperclip worktree init` is what bootstraps that instance and writes
`<repo>/.paperclip/config.json` + `.env`
> - When `--force` was passed, the init path tried to "start clean" by
recursively removing the entire `<repo>/.paperclip/` directory before
rewriting those two files
> - But `<repo>/.paperclip/` also holds `worktrees/`, which contains
every repo-managed worktree checkout (70+ on this machine). The
recursive rm silently nuked all of them.
> - This PR narrows the `--force` reset so it only deletes the two files
it's about to rewrite (`config.json`, `.env`), instead of wiping the
whole `repoConfigDir`
> - It also adds a regression test that drops a sentinel file into
`<repoConfigDir>/worktrees/` and asserts it survives a `--force` init
> - The benefit is that `worktree init --force` becomes safe to run from
inside the main repo without destroying every sibling worktree checkout

## What Changed

- `cli/src/commands/worktree.ts`: in the `--force` branch of
`runWorktreeInit`, replace `rmSync(paths.repoConfigDir, { recursive:
true, force: true })` with targeted removals of `paths.configPath` and
`paths.envPath`. `paths.instanceRoot` removal is unchanged — that path
is per-instance and safe to wipe.
- `cli/src/__tests__/worktree.test.ts`: new regression test that seeds a
fake `worktrees/<name>/` checkout inside the repo's `.paperclip/` and
verifies `runWorktreeInit({ force: true, ... })` does not delete it.

## Verification

- `pnpm --filter @paperclip/cli test -- worktree` — the new regression
test fails on the old code and passes on the fix
- Manual: from a repo checkout, `npx paperclipai worktree init --force
…` no longer removes `<repo>/.paperclip/worktrees/`; only `config.json`
and `.env` are rewritten

## Risks

- Low. The change strictly narrows what `--force` removes. Any caller
that depended on `--force` also wiping unrelated files under
`<repo>/.paperclip/` (there shouldn't be any — it's documented as just
config + env) would see those files persist. `instanceRoot` cleanup is
unchanged.

## Model Used

- Claude (Anthropic), model `claude-opus-4-7`, ~200K context,
extended-thinking + tool-use enabled, run via the Paperclip
`claude_local` adapter.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — CLI-only fix)
- [x] I have updated relevant documentation to reflect my changes (no
doc surface affected)
- [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-05-17 22:12:56 -07:00
Devin Foley 734385102c Fix new secret form textarea overflow (PAPA-348) (#6222)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Operators manage per-company secrets through the Secrets page in the
web UI
> - A long secret value pasted into the "New secret" textarea blew out
the form's width, which pushed the Create/Cancel buttons off-screen and
made the form unusable
> - Root cause: the shadcn `Textarea` primitive sets `w-full` but does
not constrain `min-width`, so a flex parent honors the textarea's
intrinsic content width when a long unbreakable string is present
> - This pull request adds `min-w-0 max-w-full` to the shared `Textarea`
primitive and `min-w-0 overflow-x-hidden break-all` on the secret-value
usage so a long token wraps inside the form bounds
> - The benefit is the Create/Cancel buttons stay reachable regardless
of pasted token length, and every other `Textarea` consumer also gets
the flexbox-friendly width constraint

## What Changed

- `ui/src/components/ui/textarea.tsx`: added `min-w-0 max-w-full` to the
base shadcn `Textarea` so it cannot exceed its flex parent
- `ui/src/pages/Secrets.tsx`: added `min-w-0 overflow-x-hidden
break-all` on the new-secret value `Textarea` so long opaque tokens wrap
instead of pushing the form
- `ui/src/pages/Secrets.render.test.tsx`: new regression test that opens
the New Secret dialog and asserts the value textarea carries the
width-constraint classes

## Verification

- `cd ui && npx vitest run src/pages/Secrets.render.test.tsx` — 3/3 pass
- Manual: open the Secrets page, click "New secret", paste a long
unbroken string (e.g. a 500-char token) into the value field. The form
stays within its dialog and the Create/Cancel buttons remain in view.

Before:

<img width="1772" height="1432" alt="image"
src="https://github.com/user-attachments/assets/cb31a290-f82a-41dc-9346-91d18cbb5911"
/>

After:

<img width="672" height="734" alt="Screenshot 2026-05-17 at 5 39 38 PM"
src="https://github.com/user-attachments/assets/a08800c2-b09b-43be-b0e8-114d9149b8f5"
/>

After: the value field wraps with `break-all` inside the dialog;
Create/Cancel stay clickable. Covered by the new render test which
asserts `min-w-0`, `overflow-x-hidden`, and `break-all` are present on
`#new-secret-value`.

## Risks

- Low risk. The base `Textarea` change adds `min-w-0 max-w-full`, which
only affects layouts where a textarea was previously allowed to grow
past its parent — those cases were already buggy. `break-all` on the
secret-value textarea is the right behavior for opaque tokens; it would
be wrong for prose, but this field is explicitly a secret token.

## Model Used

- Provider: Anthropic Claude
- Model: claude-opus-4-7 (Opus 4.7)
- Mode: standard Claude Code agent, tool use enabled

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-17 17:54:15 -07:00
Dotta d734bd43d1 [codex] Roll up May 17 branch changes (#6210)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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-05-17 17:15:06 -05:00
Dotta 705c1b8d81 [codex] Add routine env secrets support (#6212)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Scheduled routines are the control-plane path for recurring agent
work.
> - Routines already had dispatch/history, but their runtime environment
did not carry routine-owned secret bindings through execution.
> - Operators need routine-specific secrets that can override
project/agent env without exposing secret values in history, logs, or
access events.
> - This pull request adds the routine env runtime contract, wires it
into execution, and makes the routine UI/history surfaces show safe
secret metadata.
> - The benefit is that routine executions can use scoped secret refs
predictably while preserving company boundaries and auditability.

## What Changed

- Added routine env persistence/runtime support, including
`routines.env`, `routine_runs.routine_revision_id`, revision snapshots,
and idempotent migration `0086_routine_env_runtime_contract`.
- Resolved routine env during heartbeat adapter config assembly with
precedence `agent < project < routine` and secret access events recorded
against the routine consumer.
- Added secret binding synchronization for routine create/update/restore
flows and guarded cross-company, missing, disabled, and deleted secret
cases.
- Added a Secrets tab to routine detail, env/secret history diff
rendering, and Storybook coverage for the new UI states.
- Added server/UI regression tests, including an embedded-Postgres QA
path for routine secret execution and restore behavior.
- Updated implementation/database docs for routine env and
secret-binding behavior.

## Verification

- `pnpm install --frozen-lockfile` after rebasing onto
`public-gh/master` to refresh workspace links for the newly-added
upstream Grok adapter package.
- `pnpm exec vitest run
server/src/__tests__/heartbeat-project-env.test.ts
server/src/__tests__/routines-service.test.ts
server/src/__tests__/secrets-service.test.ts
server/src/__tests__/qa-routine-secrets-e2e.test.ts
ui/src/components/RoutineHistoryTab.test.tsx` passed: 5 files, 92 tests.
- `pnpm -r typecheck` passed across the workspace.
- `pnpm build` passed. Vite emitted the existing
large-chunk/dynamic-import warnings.
- UI screenshots were captured locally during QA in
`artifacts/pap-9521/` and `artifacts/pap-9522/`; generated screenshots
are not committed to avoid adding binary artifacts to the repo.

## Risks

- Migration risk is limited by `IF NOT EXISTS` guards for the new
columns, FK, and index, and the migration is ordered as `0086`
immediately after upstream `0085`.
- Runtime behavior changes env precedence for routine executions by
adding routine env as the highest-precedence layer; tests cover
agent/project/routine precedence.
- Secret handling is security-sensitive; tests cover value-free
manifests/events/errors, disabled/missing/deleted secrets, and
cross-company rejection.
- UI history now renders routine env/secret diffs; tests and Storybook
stories cover the main rendering paths.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with shell/tool use and
medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-17 16:30:34 -05:00
Dotta 3e6610fb93 docs(skills): add release-changelog-discord-message skill (#6152)
## Summary

Adds `.agents/skills/release-changelog-discord-message/SKILL.md` — the
companion to `.agents/skills/release-changelog/`. The changelog skill
produces `releases/vYYYY.MDD.P.md`; this one turns that into the single
copy-pasteable Discord post in dotta's voice and attaches it as the
`discord_announcement` document on the release issue.

Includes:

- dotta's instructions near-verbatim from PAP-3687 ("This is for discord
— try to follow my format. If I have a section where I think about the
future, pull from recent issues we're working on etc.")
- The three previous Discord announcements (v2026.403.0, v2026.416.0,
v2026.427.0) **verbatim** as canonical voice references
- Format template + language tips (ALL CAPS for excitement,
emoji-shortcode-per-highlight, first-person voice, opener/closer brand
bookends)
- Workflow tied to the existing release issue + `discord_announcement`
document
- Review checklist (version match, contributor list dedup, real "what's
next" themes, no invented work)

Resolves PAP-9524.

## Test plan

- [ ] dotta eyeballs voice + structure against the three prior posts
- [ ] On the next release, run this skill end-to-end and confirm the
`discord_announcement` document on the release issue matches the format

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-16 20:36:22 -05:00
Dotta 7bbdfb69df [codex] Enable Grok adapter canary publishing (#6154)
## Thinking Path

> - Paperclip publishes its CLI, server, UI, and adapter packages
through the shared release workflow.
> - Canary releases are driven by the GitHub release workflow on pushes
to `master`.
> - The release workflow does not publish every public package
automatically; it uses `scripts/release-package-manifest.json` as the CI
enrollment source of truth.
> - The Grok adapter is public and already present in the manifest, but
its `publishFromCi` flag was still disabled.
> - Because of that flag, normal canary publishes skipped
`@paperclipai/adapter-grok-local` even when the main package received a
canary.
> - This pull request enables Grok in the release manifest so future
canary runs include it.
> - The benefit is that Grok adapter canaries stay aligned with the rest
of the package release set.

## What Changed

- Set `packages/adapters/grok-local` / `@paperclipai/adapter-grok-local`
to `publishFromCi: true` in `scripts/release-package-manifest.json`.

## Verification

- `node ./scripts/release-package-map.mjs check`
- `node ./scripts/release-package-map.mjs list | grep
'@paperclipai/adapter-grok-local'`
- `pnpm test:release-registry`

## Risks

- Low risk: this is a release manifest-only change.
- Future canary releases will attempt to publish
`@paperclipai/adapter-grok-local`; this assumes the package remains
publishable and trusted publishing/package access are correctly
configured for the existing npm package.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with shell, git, and GitHub
tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-16 20:34:28 -05:00
Dotta 93cd933f79 docs: add v2026.517.0 release changelog (#6150)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - Stable releases need a reviewer-readable changelog artifact that
summarizes what changed for operators and contributors.
> - The last stable release tag is `v2026.513.0`, and the requested
release date is 2026-05-17.
> - The release changelog skill maps that date to stable version
`2026.517.0` and requires the range `v2026.513.0..origin/master`.
> - I reviewed the commits, PR metadata, migration/API surfaces, and
contributor attribution in that range.
> - This pull request adds the `releases/v2026.517.0.md` changelog for
human release sign-off.
> - The benefit is a stable release note artifact that is ready to
review before publishing the release.

## What Changed

- Added `releases/v2026.517.0.md` for the 2026-05-17 stable release.
- Summarized user-facing highlights, improvements, fixes, and
contributor attribution from `v2026.513.0..origin/master`.
- Omitted Breaking Changes and Upgrade Guide sections after checking for
destructive migrations, removed API surfaces, and breaking-change commit
signals.

## Verification

- `./scripts/release.sh stable --date 2026-05-17 --print-version` ->
`2026.517.0`.
- `git tag --list 'v*' --sort=-version:refname | head -10` confirmed
`v2026.513.0` is the latest stable tag.
- `git log v2026.513.0..origin/master --oneline --no-merges` reviewed
the 16 release-range commits.
- `git diff --name-only v2026.513.0..origin/master -- .changeset`
returned no changeset files.
- `git log v2026.513.0..origin/master --format='%s' | rg -n 'BREAKING
CHANGE|BREAKING:|^[a-z]+!:' || true` returned no breaking-change
signals.
- `git diff --name-only v2026.513.0..origin/master --
packages/db/src/migrations packages/db/src/schema server/src/routes
server/src/api server/src` reviewed the DB/API/server touchpoints; only
an additive document-lock migration appeared in the DB schema/migration
path.
- `test -f releases/v2026.517.0.md` passed.
- `rg -n -- '-canary|canary/' releases/v2026.517.0.md || true` returned
no canary filename/title language.
- `git diff --cached --check` passed before commit.

## Risks

- Low risk: docs-only release changelog addition.
- Changelog grouping is editorial; reviewers may want wording or
prioritization changes before release publication.
- Contributor attribution intentionally excludes bot accounts and
Paperclip founders from the Contributors section per the release
changelog skill.

> Checked [`ROADMAP.md`](ROADMAP.md); this is release documentation, not
new roadmap-level core feature work.

## Model Used

- OpenAI Codex, GPT-5 coding agent via Paperclip `codex_local`, with
shell, git, GitHub CLI, and repository file editing enabled. Exact
backend sub-version and context window were not surfaced by the
Paperclip runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-16 19:32:24 -05:00
Devin Foley 573e9ec909 fix(grok-local): restore turn boundaries in streaming reasoning text (#6142)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The `grok-local` adapter streams reasoning text to the issue
"Working..." panel as the grok CLI runs
> - The `grok` CLI's `--output-format streaming-json` mode silently
drops the `\n` separator between reasoning turns around tool calls
> - Consecutive `thought` chunks (e.g. `` "`" `` followed by `"The"`)
arrive with no intervening whitespace event, so the UI's `delta: true`
concatenator merged them into run-on text like `"…planningGreat, now I
have the issue descriptionThe only co"`
> - This PR adds a small turn-boundary helper that detects sentence
boundaries in the upstream `thought` stream and inserts a single `\n`
only when the previous chunk ended with sentence punctuation (or a
balanced closing backtick) AND the next chunk begins a new uppercase
sentence
> - The benefit is readable streaming reasoning in the UI without
changing how completed messages are stored

## What Changed

- Added `packages/adapters/grok-local/src/shared/turn-boundary.ts` with
per-stream state (last chunk + backtick parity) and a
`restoreTurnBoundary()` helper that inserts `\n` only between balanced,
sentence-terminated `thought` chunks
- Wired the helper into `parseGrokJsonl` (server) and added a new
`createGrokStdoutParser` factory used by `grokLocalUIAdapter` for the
live "Working..." panel
- Added focused tests in `shared/turn-boundary.test.ts`, plus regression
assertions in `server/parse.test.ts` and `ui/parse-stdout.test.ts`

## Verification

- `pnpm --filter @paperclip/grok-local test` — 23/23 adapter tests pass
- `pnpm --filter @paperclip/grok-local typecheck` and UI typecheck —
clean
- Replayed an actual broken `grok 0.1.210` stream from the report;
previously-merged boundaries (`` `ls`The ``, `returned:Confirmed`) now
render with a separating newline; chunks inside un-closed backtick spans
are left alone

## Risks

- Low risk. Boundary insertion only fires when prev ends with
`.`/`!`/`?`/balanced `` ` `` and next begins with an uppercase ≥2-char
word, with no whitespace on either side. Worst case: a rare missed split
or a misplaced newline inside reasoning — both purely cosmetic and
confined to the live streaming panel.

## Model Used

- Claude Opus 4.7 (claude-opus-4-7), Anthropic, extended thinking + tool
use via Claude Code

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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-05-16 11:48:51 -07:00
Devin Foley 81d18f2d77 ci: speed up PR verify workflow (#6137)
## Thinking Path

> - Paperclip orchestrates AI agents through a control-plane repo that
relies on GitHub Actions as part of its release and verification safety
net.
> - The PR workflow in `.github/workflows/pr.yml` is the core CI path
protecting pull requests before merge.
> - Baseline measurement work in [PAPA-335](/PAPA/issues/PAPA-335)
showed the old single `verify` job was the critical-path bottleneck,
with general tests and build serialized together.
> - Follow-up implementation in [PAPA-338](/PAPA/issues/PAPA-338) and
[PAPA-339](/PAPA/issues/PAPA-339) split that work into parallel lanes
and removed redundant clean-runner prebuild work.
> - [PAPA-340](/PAPA/issues/PAPA-340) now needs real post-change PR
workflow evidence, not local inference, to compare against the May 15,
2026 baseline and decide whether phase-2 work is still justified.
> - This pull request publishes the already-implemented CI speedup
branch so GitHub can run the actual `PR` workflow against it.
> - The benefit is that CI timing decisions are based on measured runs
from the exact workflow shape we intend to ship.

## What Changed

- Split the PR workflow so `policy` fans out into separate `Typecheck +
Release Registry`, grouped `General tests`, and `Build` jobs.
- Kept the serialized server matrix, canary dry run, and e2e jobs intact
while removing the old monolithic `verify` bottleneck.
- Reworked grouped general-test execution in
`scripts/run-vitest-stable.mjs` so the workflow can run balanced
non-serialized lanes.
- Replaced redundant clean-runner prebuild gates with the idempotent
`ensure-build-deps` path used by the relevant CI entrypoints.

## Verification

- `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/pr.yml');
puts 'yaml-ok'"`
- `node scripts/run-vitest-stable.mjs --mode general --dry-run`
- `node scripts/run-vitest-stable.mjs --mode general --group
general-server --dry-run`
- `node scripts/run-vitest-stable.mjs --mode general --group
general-workspaces-a --dry-run`
- `node scripts/run-vitest-stable.mjs --mode general --group
general-workspaces-b --dry-run`
- `pnpm test:run:general -- --group general-workspaces-b`
- `pnpm test:run:general -- --group general-workspaces-a`
- `pnpm test:run:general -- --group general-server`
- `pnpm run typecheck:build-gaps`
- `pnpm --filter @paperclipai/plugin-hello-world-example typecheck`

## Risks

- Required-check and branch-protection settings may still reference the
old single `verify` job name.
- Parallel CI lanes can expose hidden ordering assumptions or
clean-runner bootstrap gaps that local grouped dry-runs did not surface.
- Because the branch is behind current `master`, merge conflicts or
unrelated upstream drift could affect the measured runtime until the
branch is rebased.

> Checked `ROADMAP.md`; this work is CI throughput maintenance for the
existing PR verification path, not duplicate feature work.

## Model Used

- OpenAI Codex via Paperclip `codex_local`, GPT-5-class coding agent
with repository read/write, shell execution, and GitHub CLI/tool use.
The runtime does not expose a more specific backend model ID in-session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-16 11:28:25 -07:00
github-actions[bot] 9b6d2e6b79 chore(lockfile): refresh pnpm-lock.yaml (#6136)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-05-16 10:13:22 -07:00
Devin Foley ab8b471685 Add built-in grok_local adapter (#6087)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, so
adapter quality directly affects what runtimes the control plane can
supervise.
> - Local CLI adapters are one of the core execution surfaces because
they turn real coding tools into Paperclip-managed employees with
heartbeats, transcripts, and reviewability.
> - Grok Build was installed on the Paperclip host, but Paperclip had no
built-in `grok_local` adapter, so the runtime could not be configured
through the normal server/UI/CLI adapter path.
> - That gap needed to be closed with the same built-in registry,
environment diagnostics, transcript parsing, and skill/instructions
behavior that the other local adapters already rely on.
> - After the initial adapter landed, a real follow-up run showed that
Grok streaming text was being rendered one fragment per line, which made
transcripts harder to read even though the runtime itself was working.
> - This pull request adds the built-in `grok_local` adapter end-to-end
and then fixes the transcript parser so streamed Grok output is
coalesced into readable assistant/thinking blocks.
> - The benefit is that Grok Build becomes a first-class Paperclip
runtime with a usable operator experience instead of a partially wired
runtime with noisy transcript output.

## What Changed

- Added a new built-in `@paperclipai/adapter-grok-local` package with
server, UI, and CLI entrypoints.
- Implemented Grok execution, session handling, environment diagnostics,
config building, skill syncing, and parser coverage inside the new
adapter package.
- Registered `grok_local` across the built-in adapter inventories and
capability/display metadata in server, UI, CLI, and shared constants.
- Added adapter route coverage for the new built-in type.
- Fixed Grok transcript readability by emitting streamed `text` and
`thought` fragments as deltas so the shared transcript builder coalesces
them into readable message blocks.
- Added regression tests for the Grok parser and transcript coalescing
behavior.

## Verification

- `pnpm vitest run
packages/adapters/grok-local/src/ui/parse-stdout.test.ts
ui/src/adapters/transcript.test.ts`
- `pnpm --filter @paperclipai/adapter-grok-local build`
- Manual runtime verification on the Paperclip host during
implementation and follow-up review:
  - confirmed the Grok CLI was installed and authenticated
- confirmed the worktree dev server could be restarted cleanly and
health-checked after the parser follow-up
- No screenshots attached. This change is primarily adapter plumbing
plus transcript formatting behavior; reviewers can verify via the
Grok-backed run surfaces directly.

## Risks

- This adds a new built-in adapter, so any missed registration surface
could create inconsistencies between server, UI, and CLI behavior.
- The adapter depends on Grok Build's current event/output shape; if
upstream Grok streaming JSON changes, transcript parsing or session
extraction may need follow-up updates.
- The transcript readability fix intentionally changes how Grok
fragments are grouped, so any downstream code that implicitly expected
one entry per fragment would behave differently.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex via Paperclip `codex_local` agent runtime.
- GPT-5-class coding model with tool use, shell execution, file editing,
and repo inspection enabled.
- Exact backend model ID/context window were not surfaced to the agent
in this Paperclip session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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-05-16 09:51:09 -07:00
Dotta 63821bfe4c [codex] Add full locale catalog (#6070)
## Thinking Path

> - Paperclip orchestrates AI agents through a board-facing control
plane.
> - The UI is the operator surface where onboarding and company creation
flows need to remain understandable across languages.
> - The i18next foundation now supports locale resource loading and
validation, but only English was present on `master`.
> - The branch exists to populate that foundation with the supported
language catalog without changing routing, data contracts, or runtime
behavior.
> - This pull request adds locale JSON resources for the current
non-English language set.
> - The benefit is that future locale selection work has validated
message catalogs ready for the first translated onboarding strings.

## What Changed

- Added 39 localized message catalogs under `ui/src/i18n/locales/` for
the existing no-companies onboarding strings.
- Kept the PR rebased onto current `master` so it only contains the
all-languages layer on top of the already-merged i18next foundation.

## Verification

- `pnpm exec vitest run ui/src/i18n/locale-validation.test.ts`
- Checked `ROADMAP.md`; this is scoped locale catalog follow-up work and
does not duplicate a listed roadmap feature.
- No before/after screenshots included because this PR only adds
resource JSON files and does not change rendered layout or visible
default-English UI behavior.

## Risks

- Low risk: static JSON resource additions validated against the English
reference schema.
- Translation quality may need native-speaker review before enabling
visible locale switching broadly.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex CLI, GPT-5 family coding agent, tool-enabled repository
access, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-16 08:24:31 -05:00
Dotta 4c47eb46c3 [codex] Add multilingual issue preservation coverage (#6069)
## Thinking Path

> - Paperclip orchestrates AI agents for autonomous companies.
> - Agents and board operators coordinate through company-scoped issues,
comments, documents, and heartbeat wake payloads.
> - Chinese, Japanese, and Hindi text needs to survive the full issue
lifecycle without normalization or prompt serialization damage.
> - The riskiest paths are board issue creation, server
issue/comment/document round-tripping, and scoped wake prompt rendering.
> - This pull request adds focused regression coverage across those
surfaces.
> - The benefit is higher confidence that multilingual operators and
agents can create, search, comment on, complete, and wake on issues
using non-Latin text.

## What Changed

- Added adapter-utils wake payload and prompt rendering coverage for
Chinese, Japanese, and Hindi issue/comment text.
- Added UI New Issue dialog coverage proving multilingual title and
description text is submitted unchanged.
- Added server route coverage that round-trips multilingual issue text
through create, search, comments, documents, completion comments, and
heartbeat context.
- Addressed Greptile feedback by using a typed storage mock and
splitting the server route integration path into smaller ordered
assertions.

## Verification

- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
ui/src/components/NewIssueDialog.test.tsx
server/src/__tests__/multilingual-issues-routes.test.ts`
- Result: 3 test files passed, 51 tests passed.

## Risks

- Low risk: this PR adds regression coverage only and does not change
runtime behavior.
- The new server test uses embedded Postgres support and skips on
unsupported hosts using the existing helper pattern.
- No migrations are included.
- No `pnpm-lock.yaml` changes are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 based coding agent, with shell, git, Vitest, and
GitHub connector/CLI tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-15 12:49:57 -05:00
Dotta e2d7263b07 [codex] Add minimal i18next i18n foundation (#5943)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a web control
plane.
> - The UI currently renders operator-facing copy directly from React
components.
> - Internationalization needs a smallest-possible starting point before
broader locale work can proceed.
> - The package declarations for `i18next` and `react-i18next` landed
separately, so this PR can stay focused on the implementation slice.
> - The implementation keeps the first surface English-only and
deliberately tiny while using the established `i18next` +
`react-i18next` runtime.
> - Future language contributions should be able to add a single locale
JSON file, with validation guarding key shape, interpolation parity,
suspicious payloads, and string length.
> - Locale strings must remain display-only UI copy and must not flow
into prompts, agent instructions, tool calls, shell commands, issue
content, approvals, adapter config, or other LLM-visible control paths.

## What Changed

- Initialized `i18next` behind the existing `@/i18n` boundary with fixed
English resources, fallback English, no detector plugin, no backend
plugin, no language picker, and no rich-text translation component.
- Kept `ui/src/i18n/locales/en.json` as the English source locale and
converted the validated JSON locale registry into i18next resources
before app rendering.
- Routed the no-companies start page title, description, and button
through `t(key, { defaultValue })` while preserving unchanged rendered
English copy.
- Added locale validation and focused Vitest coverage for missing/extra
keys, non-string leaves, interpolation parity, suspicious
executable/link payloads, and length caps.
- Addressed Greptile i18n review feedback: case-insensitive
event-handler detection, multi-violation diagnostics,
future-locale-friendly registration test, surfaced i18next init errors,
and removed the redundant side-effect import.
- Rebasing note: rebased onto current `public-gh/master` after the
package-only PR landed; this PR no longer changes `ui/package.json` or
`pnpm-lock.yaml`.

## Verification

- `pnpm install --no-lockfile --ignore-scripts` to install local
dependencies without reading or writing `pnpm-lock.yaml`.
- `pnpm --filter @paperclipai/ui exec vitest run
src/i18n/locale-validation.test.ts` -> passed, 7 tests.
- `pnpm --filter @paperclipai/ui typecheck` -> passed.
- `git diff --name-only public-gh/master...HEAD` shows only i18n
implementation files and the touched App copy call site; no package
manifest or lockfile changes remain in this PR.
- Visual impact is intentionally unchanged for the touched no-companies
copy because the English translations match the previous literal
strings.

## Risks

- Locale validation reduces prompt-injection risk, but the main safety
invariant is architectural: locale strings must remain display-only and
must never be used as LLM-visible control text.
- This intentionally does not add non-English locales, a language
picker, browser detection, HTTP/backend locale loading, server
localization, adapter localization, broad copy migration, or new package
scripts.
- Repository-wide CI may still depend on the separate lockfile-refresh
workflow for the already-merged package declaration, but this PR no
longer introduces package manifest or lockfile changes itself.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning
mode.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why screenshots are not applicable because
there is no intended visual change
- [x] I have updated relevant documentation to reflect my changes, or
confirmed no docs changed because behavior/commands did not change
- [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-05-15 11:11:02 -05:00
Emad Ibrahim afb73ba553 Scale issue kanban board for high-volume columns (#5309)
## Thinking Path

> - Paperclip is a control plane for autonomous AI-agent companies, and
the board UI needs to keep operator visibility clear as company work
scales.
> - The involved subsystem is the Issues page board mode, specifically
the Kanban rendering path for issue status columns.
> - The current board keeps the classic Kanban model, but high-volume
columns can become tall, slow, and hard to scan when hundreds of issues
are loaded.
> - We explored alternatives and chose the conservative Scaled Kanban
direction: preserve status lanes and drag/drop, but bound visible cards
and collapse low-signal lanes.
> - This pull request adds UI-only density controls and high-volume
defaults rather than introducing schema or API changes.
> - The benefit is a board that remains usable with large issue
inventories while keeping active workflow lanes visible.

## What Changed

- Added scaled Kanban behavior with compact cards, collapsed cold-lane
rails, per-column visible-card limits, and per-column "show more" reveal
controls.
- Added persisted board density preferences to the Issues page view
state, scoped through the existing company-specific localStorage path.
- Added board toolbar controls for compact cards, collapsed cold lanes,
cards-per-column page size (`10`, `25`, `50`), and density reset.
- Added a design spec and implementation plan under `doc/plans/`.
- Added focused Vitest coverage for `KanbanBoard` and `IssuesList`
high-volume board behavior.

## Verification

- `pnpm exec vitest run ui/src/components/IssuesList.test.tsx
ui/src/components/KanbanBoard.test.tsx` — pass, 35 tests.
- `pnpm -r typecheck` — pass.
- `pnpm build` — pass before the upstream merge; not rerun after
docs/assets cleanup.
- `curl -fsS http://127.0.0.1:3100/api/health` — pass against restarted
local dev server after applying pending migration
`0078_white_darwin.sql`.
- `pnpm test:run` — previously failed in unrelated Cursor remote-sandbox
server tests:
- `server/src/__tests__/cursor-local-adapter-environment.test.ts`:
expected probe status `pass`, received `fail`.
- `server/src/__tests__/cursor-local-execute.test.ts`: two remote
sandbox execution cases exited `127` instead of `0`.

Local dev server for manual UI inspection: `http://127.0.0.1:3100`.

Screenshots were captured for review and attached in the PR thread
rather than committed to source.

## Risks

- Low schema/API risk: this is UI-only and uses the existing issue data
path.
- Board users may need to notice the new density controls if they want
to override high-volume defaults.
- Collapsed cold lanes remain valid drop targets, so status moves can
happen without expanding the destination lane.
- Very large remote columns can still hit the existing 200-item
per-column query cap; this PR improves rendering, not server-side board
pagination.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, local test/build execution, and inline implementation
planning. No subagents were used.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
2026-05-15 10:53:09 -05:00
github-actions[bot] 7e1a27c8ec chore(lockfile): refresh pnpm-lock.yaml (#6062)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-05-15 10:32:10 -05:00
Dotta d5ba3348a9 [codex] Add UI i18n runtime packages (#6058)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a web control
plane.
> - The UI i18n slice needs `i18next` and `react-i18next` available as
runtime packages before the implementation PR can stay focused on code
changes.
> - The implementation PR should not mix package declaration work with
Greptile-driven i18n code feedback.
> - This pull request isolates only the package manifest additions
requested by the maintainer.
> - The benefit is a tiny dependency-declaration PR that can be reviewed
and merged independently before rebasing the i18n implementation PR.

## What Changed

- Added `i18next` to `ui/package.json` dependencies.
- Added `react-i18next` to `ui/package.json` dependencies.
- Intentionally did not change `pnpm-lock.yaml`, matching the repository
policy that PRs do not commit lockfile changes.

## Verification

- `node -e
"JSON.parse(require('fs').readFileSync('ui/package.json','utf8'));
console.log('ui/package.json valid JSON')"`
- `git diff --name-only public-gh/master...HEAD` shows only
`ui/package.json`.
- `npm view i18next version` -> `26.2.0`.
- `npm view i18next@26.1.0 version` -> `26.1.0`.
- `npm view react-i18next version` -> `17.0.8`.
- `npm view react-i18next@17.0.7 version` -> `17.0.7`.
- Did not run `pnpm install --frozen-lockfile` because this PR
intentionally changes only `ui/package.json` and leaves lockfile
handling to the repository's separate lockfile workflow.

## Risks

- CI jobs that run `pnpm install --frozen-lockfile` may fail until the
repository lockfile workflow handles these package declarations.
- Low behavioral risk: this PR does not import or execute the packages
and changes no runtime code.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5, tool-enabled coding agent in medium reasoning
mode.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run the applicable local validation for this manifest-only
change
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why screenshots are not applicable because
there is no runtime UI change
- [x] I have updated relevant documentation to reflect my changes, or
confirmed no docs changed because behavior/commands did not change
- [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-05-15 10:31:01 -05:00
Dotta eb38b226c2 Fix LLM Wiki package and migration validation (#6010)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Plugins extend the control plane with optional capabilities such as
LLM Wiki.
> - LLM Wiki needs its package assets and plugin-owned database
migrations to work when installed from the packaged plugin.
> - The bundled spaces migration used validation-hostile dynamic SQL,
and the packaged plugin could omit non-dist runtime assets.
> - This pull request makes the LLM Wiki package include its required
assets and cuts the spaces migration over to explicit, idempotent SQL
that passes the production plugin database validator.
> - The benefit is a simpler plugin install path that validates and
applies the bundled LLM Wiki migrations without adding plugin-specific
legacy handling to Paperclip core.

## What Changed

- Added the LLM Wiki package asset allowlist so agents, migrations,
skills, templates, dist output, and README are included when packaged.
- Renamed the bootstrap `.gitignore` template to `gitignore.template`
and updated the runtime lookup so package tooling does not drop the
hidden template file.
- Relaxed plugin migration validation to allow namespace-scoped
`INSERT`/`UPDATE` backfills and `CREATE INDEX` statements while
continuing to reject destructive or cross-namespace SQL.
- Replaced the LLM Wiki spaces migration's dynamic constraint-drop DO
block with explicit `DROP CONSTRAINT IF EXISTS` statements.
- Replaced fragile regex-source dispatch in SQL reference extraction
with explicit capture-group descriptors.
- Added regression coverage that applies the bundled LLM Wiki migrations
through the production validator and checks the expected constraints.

## Verification

- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/plugin-database.test.ts --pool=forks
--poolOptions.forks.isolate=true`
- `pnpm --filter @paperclipai/plugin-llm-wiki build`
- `git diff --check`
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.

## Risks

- Low migration risk for current users: LLM Wiki spaces are new, so this
intentionally cuts over the plugin migration instead of adding legacy
handling in core.
- Validator behavior is broader than before, but still requires fully
qualified plugin namespace targets, blocks deletes/destructive DDL, and
keeps public table access read-only and allowlisted.

> Checked [`ROADMAP.md`](ROADMAP.md); this is a targeted plugin
packaging/migration fix and does not duplicate planned core feature
work. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 based coding agent, tool-enabled local repo
access, reasoning mode managed by the Paperclip/Codex runtime. Exact
context window was not surfaced in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-15 10:20:02 -05:00
Dotta dfcebf082b [codex] Refresh issue documents from live updates (#6005)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The board UI keeps issue pages responsive by subscribing to live
activity events and invalidating TanStack Query caches.
> - Issue documents are first-class issue artifacts, but document
activity events were not refreshing the document list, active document,
or revision caches.
> - That meant a user could update a document on an issue and another
open board would keep showing stale document content until a page
reload.
> - This pull request routes issue document activity events through the
same live invalidation path used for issue and comment updates.
> - The benefit is that issue document changes become visible
automatically on active issue pages without forcing operators to reload
the board.

## What Changed

- Added live-update cache invalidation for `issue.document_created`,
`issue.document_updated`, `issue.document_restored`, and
`issue.document_deleted` activity events.
- Invalidated the issue document list, the active document cache, and
document revisions for both issue id and identifier references when the
activity payload includes a document key.
- Added regression coverage for document activity events so active issue
pages refetch document caches without inactive-only behavior.
- Simplified the document invalidation test mock after Greptile feedback
so the test only models the cache reads it actually uses.

## Verification

- `git rebase public-gh/master` reported the branch was up to date after
fetching `public-gh/master`.
- `pnpm run preflight:workspace-links` passed.
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/context/LiveUpdatesProvider.test.ts` passed: 1 file, 16 tests.
- `pnpm --filter @paperclipai/ui typecheck` passed.
- PR checks passed on `eecd19f7b0355490f17314c94bffa06aada8f9e3`:
`policy`, `verify`, `e2e`, all 4 serialized server shards, `Canary Dry
Run`, `security/snyk`, and `Greptile Review`.
- Greptile completed with `5/5` confidence and no unresolved review
threads.

## Risks

- Low risk. This expands cache invalidation for existing live activity
events and does not change API contracts, database schema, migrations,
or document persistence behavior.
- No migrations or `pnpm-lock.yaml` changes are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled local repository
workflow.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] No visible UI layout changed; screenshots are not applicable for
live cache invalidation behavior
- [x] No documentation changes were needed for this internal UI cache
refresh fix
- [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-05-15 08:55:54 -05:00
Dotta 03ad5c5bea [codex] Add issue document locking (#6009)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through company-scoped
issues, comments, and issue documents.
> - Issue documents are the durable place where plans, handoffs, and
other work artifacts are revised over time.
> - Some documents need to be preserved as operator-approved snapshots
while agents continue working on the same issue.
> - Without document locking, a later board or agent write can overwrite
the document key that reviewers expected to remain stable.
> - This pull request adds board-managed issue document locks and makes
agent writes to locked keys create a derived document instead of
mutating the locked document.
> - The benefit is safer document handoffs: approved or frozen issue
documents stay immutable until the board explicitly unlocks them.

## What Changed

- Added `locked_at`, `locked_by_agent_id`, and `locked_by_user_id`
document fields plus migration `0085_tranquil_the_executioner.sql`.
- Added document lock/unlock service behavior, route endpoints, activity
events, and locked-document write protections.
- Made agent document writes to locked keys create a new derived key
such as `plan-2` rather than overwriting the locked document.
- Surfaced lock state through shared issue document types, UI API
methods, document header lock controls, and activity formatting.
- Added server and UI tests for lock/unlock behavior, locked document
immutability, and UI action visibility.
- Updated `doc/SPEC-implementation.md` with the V1 document lock
contract and endpoints.

## Verification

- `git rebase public-gh/master` completed cleanly after committing the
branch changes.
- `git diff --check` passed before commit.
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
ui/src/components/IssueDocumentsSection.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/lib/document-revisions.test.ts` passed: 5 files, 32 tests.

## Risks

- Medium risk because this changes the document persistence contract and
adds a migration.
- The migration uses `ADD COLUMN IF NOT EXISTS` and guarded foreign-key
creation so it remains safe for users who may have already applied an
earlier copy of the migration.
- Locked documents intentionally reject board edits/deletes/restores
until unlocked; any existing workflows that expected direct overwrite
need to unlock first.
- Agent writes to locked keys now create derived documents, which may
create extra issue documents when agents retry locked writes.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with tool use and local code
execution in the Paperclip worktree.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] 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-05-15 08:54:55 -05:00
Devin Foley 901c088e14 fix: propagate projectId into wakeup context and support identifier lookup (#6026)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The server's heartbeat/wakeup pipeline resolves which project
workspace an agent run should bind to
> - `enqueueWakeup` resolves an issue (and therefore a project) before
scheduling a run, but the resolved `projectId` was never written back
into `enrichedContextSnapshot.projectId`, so `resolveWorkspaceForRun`
always saw `contextProjectId === null`
> - When the `issueProjectRef` DB lookup also returned null (e.g.
identifier-style id like `ENV-13`, not a UUID), workspace resolution
fell through to the `agent_home` fallback instead of the correct project
workspace
> - Surfaced while running the QA matrix on sandbox/SSH — runs were
ending up in the wrong workspace
> - This pull request stores the resolved `projectId` back into context
and replaces the raw UUID-only DB query with `issuesSvc.getById`, which
accepts both UUIDs and identifiers and canonicalizes `context.issueId` /
`context.taskId` to the UUID on identifier hits
> - The benefit is that wakeups triggered with identifier-style ids
correctly bind to their project workspace instead of silently degrading
to `agent_home`

## What Changed

- In `enqueueWakeup`, after the issue resolves, write `projectId` back
into `enrichedContextSnapshot.projectId` so downstream workspace
resolution can use it.
- Replace the raw UUID-only DB query for the issue with
`issuesSvc.getById`, which handles both UUIDs and identifiers (e.g.
`ENV-13`).
- On an identifier hit, canonicalize `context.issueId` and
`context.taskId` to the resolved UUID.

## Verification

- Trigger a wakeup with an identifier-style id (`ENV-13`) on the dev
instance and confirm the run binds to the correct project workspace
instead of `agent_home`.
- Confirm UUID-style wakeups still resolve to the same project workspace
as before.

## Risks

- Low risk. Scope is a single function in
`server/src/services/heartbeat.ts` (+20/-7). Failure mode if regressed
is the prior behavior (fallback to `agent_home`).

## Model Used

- Claude (Anthropic), `claude-opus-4-7`, via Claude Code / Paperclip
`claude_local` adapter.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-14 22:09:16 -07:00
Dotta 333a16b035 Fix company export with missing run logs (#5960)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - Company export/import lets operators move company state, including
issue threads and agent execution context, between Paperclip instances.
> - Issue comments can be enriched by nearby heartbeat run logs so
exported threads preserve useful agent/run attribution metadata.
> - Some local instances can have heartbeat run database rows whose
local log files were deleted or never copied into the current workspace.
> - The export path should still include the original user comments
instead of failing because optional run-log metadata is unavailable.
> - This pull request makes comment run-log metadata derivation tolerate
missing local log files, logs the missing-file condition for operators,
and adds a regression test.
> - The benefit is safer company exports for real instances with
incomplete local run-log storage.

## What Changed

- Treat missing local heartbeat run logs as absent optional metadata
while listing issue comments.
- Emit a structured warning with `runId` and `logRef` when optional
comment-attribution log content is missing.
- Preserve the existing error behavior for non-404 run-log read
failures.
- Added a regression test proving user comments still list when a
candidate attribution run has a missing local log reference.

## Verification

- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"candidate attribution run log is missing"` passed: 1 selected test
passed, 47 skipped.
- `pnpm --filter @paperclipai/server typecheck` passed.
- Greptile Review passed with Confidence Score 5/5 and zero unresolved
threads on commit `f68cac02bf98d7d31e7831e5bdfa95cffa85e254`.
- GitHub PR workflow run succeeded: `policy`, `verify`, four serialized
server suites, `e2e`, and `Canary Dry Run` all passed.
- `security/snyk (cryppadotta)` passed.
- Confirmed this branch is on top of `public-gh/master` and
`pnpm-lock.yaml` is not in the PR diff.

## Risks

- Low risk. The change only softens optional comment metadata derivation
for 404/missing local log files; other log read errors still throw.
- Exported comments in this edge case may lack derived run metadata, but
they remain visible/exportable instead of failing the request.
- Operators may see new warnings when historical run-log references
point to missing local files; those warnings indicate degraded optional
metadata, not data loss.

## Model Used

- OpenAI Codex, GPT-5 coding agent in this Paperclip heartbeat, with
shell/git/GitHub CLI tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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-05-14 08:37:04 -05:00
Devin Foley 1bd44c8a0d Harden Cloudflare sandbox execution (#5967)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Remote-managed adapters need sandbox/environment execution to behave
like real agent runs, not just local host probes.
> - The Cloudflare sandbox path was the weakest leg in the SSH +
Cloudflare QA matrix because bridge execution could truncate output,
time out long-running installs, and under-provision the worker instance.
> - That made several adapters fail for reasons unrelated to their
actual business logic, which blocks confidence in Paperclip's non-local
environment model.
> - This pull request hardens the Cloudflare bridge/runtime path and
adjusts sandbox probe budgets so adapter verification matches the
measured behavior of the fixed environment.
> - It also corrects the Pi sandbox install command so the QA matrix
exercises a real, supported install path.
> - The benefit is a materially more reliable SSH + Cloudflare adapter
matrix with fewer false negatives and clearer failure boundaries.

## What Changed

- Switched the Cloudflare bridge worker instance type to `standard-2`
for the QA-matrix execution path.
- Raised Cloudflare bridge/plugin-worker timeout budgets and added SSE
keepalives so long-running install/exec calls can complete instead of
dying at the transport layer.
- Fixed Cloudflare bridge-channel command handling to avoid dropped
final stdout chunks on short-lived execs.
- Made Claude, OpenCode, and Cursor sandbox probe timeouts
configurable/sandbox-aware, then tightened the defaults to the measured
post-fix range.
- Updated the Pi sandbox install command to use the package currently
installed by the official `pi.dev` installer, pinned to a specific npm
version.
- Added/updated tests around Cloudflare bridge behavior and adapter
sandbox probe paths.

## Verification

- `pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `pnpm --filter @paperclipai/adapter-opencode-local typecheck`
- `pnpm --filter @paperclipai/adapter-cursor-local typecheck`
- `pnpm vitest run packages/adapters/cursor-local
packages/adapters/claude-local packages/adapters/opencode-local
packages/adapters/pi-local packages/plugins/sandbox-providers/cloudflare
server/src/services/__tests__/plugin-worker-manager.test.ts`
- Manual QA on the dedicated dev instance using the SSH + Cloudflare
environment matrix (`ENV-29` through `ENV-40`). Clean end-to-end passes:
SSH `claude_local`, `codex_local`, `cursor`, `gemini_local`; Cloudflare
`claude_local`, `codex_local`, `cursor`, `gemini_local`.

## Risks

- Cloudflare sandbox cost increases because the bridge worker now runs
on `standard-2` instead of `lite`.
- Higher timeout ceilings can delay surfacing truly hung Cloudflare
bridge calls, even though they remove transport-level false negatives.
- The manual heartbeat matrix still exposed follow-on
execution/sync/disposition bugs in `opencode_local` and `pi_local`;
those are not fixed by this PR.

## Model Used

- OpenAI `gpt-5.4` via Paperclip `codex_local`, reasoning effort `high`,
tool use enabled, repo search enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (not applicable)
- [x] I have updated relevant documentation to reflect my changes (not
applicable)
- [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-05-13 22:00:10 -07:00
681 changed files with 142650 additions and 2983 deletions
@@ -0,0 +1,406 @@
---
name: release-changelog-discord-message
description: >
Write the Discord release announcement for a stable Paperclip release. Companion
to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`;
this one turns that file into a single copy-pasteable Discord post in dotta's
voice and attaches it as the `discord_announcement` document on the release
issue.
---
# Release Discord Announcement Skill
Write the Discord release announcement for the **stable** Paperclip release.
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
`discord_announcement` document on the release issue.
## What dotta said
> This is for discord — try to follow my format. If I have a section where I
> think about the future, pull from recent issues we're working on etc.
The Discord announcement is **not** the changelog. The changelog is exhaustive;
the announcement is opinionated, in-voice, and built around the same handful of
shipped highlights plus a real "what's next" + "what's on my mind" pulled from
current Paperclip work — not invented.
## When to use
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
release worktree/PR.
- When the release issue (the one assigned by the release routine) asks for a
Discord announcement, or has a `discord_announcement` document that needs to
be refreshed for a new date/version.
- Never run this in isolation. The version, date, contributor list, and
highlight set MUST match the matching changelog file — if the changelog has
been updated, refresh this too.
## Output
A single fenced markdown code block, ready to paste into Discord. Attached as
issue document key `discord_announcement` on the release issue, and pasted
verbatim into a comment on that issue so the human can copy it out.
```bash
PUT /api/issues/{releaseIssueId}/documents/discord_announcement
{
"title": "Discord announcement",
"format": "markdown",
"body": "<the announcement>",
"baseRevisionId": "<latest if updating>"
}
```
If the document already exists, fetch it first and pass the current
`baseRevisionId`. Never overwrite silently — if the version has changed since
the document was last written, mention what changed in the issue comment.
## Format (follow this template)
Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the
Unicode emoji. Discord renders the shortcodes; the changelog file uses prose.
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip:
OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others
## Highlights
:emoji: **Feature Name** - one-sentence description in dotta's voice.
:emoji: **Feature Name** - …
:emoji: **Feature Name** - …
... and a long tail of {flavor of the rest}. Read the [full release notes](<github link>).
## WHATS NEXT (:motorway: Roadmap)
* **Theme A** - one-line forward-looking blurb
* **Theme B** - …
* **Theme C** - …
## What's on my mind
* **Topic** - what's bugging dotta / what's queued / open questions
* **Topic** - …
## PRESS (optional — only if there is real press)
* **Outlet / Person** - what happened ([link](<x.com link>))
## WHAT I NEED FROM YOU (optional — only if there's a real ask)
FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one
TELL ME if you're using Paperclip in your business - I want to meet you
## Community
Thank you to everyone who contributed to this release!
```
@username1, @username2, @username3
```
## In Summary
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
Every single person will be managing a team of a dozen, or a hundred, or a
thousand agents and Paperclip will be the default tool to manage it all.
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md
||@everyone||
```
Notes on the template:
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
part of the brand — keep them.
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
and stay consistent within a single post.
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
exactly once when the spoiler is removed by the poster.
## Language tips
These are extracted from how dotta has written the last several announcements.
Mimic this register; do not invent a "professional" tone.
- **First person, conversational.** "I want to meet companies using Paperclip",
"what's on my mind", "if that's you let me know". Not "Paperclip is excited
to announce".
- **ALL CAPS for excitement and asks**, especially in the opener, the section
headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not
ALL-CAPS feature descriptions.
- **One emoji shortcode per highlight bullet**, picked to evoke the feature
(`:lock:` for secrets, `:brain:` for planning, `:mag:` for search,
`:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for
history/restore, `:thread:` for threads, etc.).
- **Highlight bullets are one sentence**, opinionated, told from the user's
perspective — "the cloud-secrets prereq is real now", not "added support
for…".
- **Tail line after highlights** wraps the rest in a single sentence and links
to the full release notes ("… and a long tail of {flavor}. Read the [full
release notes](url).").
- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 35
bullets is the right size. Pull these from active goals, in-flight projects,
and recent issues the team is working on — do not invent themes.
- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps,
philosophical positioning ("we're the human control plane for ai labor"),
invitations ("if you've ever wanted to write about how you use Paperclip,
hit me up"). Pull real tensions from recent issues/comments; do not invent.
- **Press section** is optional. Only include it if there is real press in the
release window (a tweet, a podcast, a talk, a star milestone). No press →
drop the section entirely.
- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask
(follow the twitter, intros, beta sign-ups). No real ask → drop it.
- **Community** is the same contributors list that's in the changelog file,
fenced in a triple-backtick block, comma-separated `@username, @username`.
Exclude bots and Paperclip founders, same rules as the changelog skill.
- **The "In Summary" mission line** evolves slowly. Use the most recent
variant unless dotta tells you otherwise. Recent variants:
- "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK"
- "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY"
- "Paperclip will be _the_ control plane for AI agents in **every** company."
- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip:
:paperclip:`. Keep it.
## Workflow
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
Use the version and contributor list from that file — never re-derive them.
2. Read the **release issue thread** (the one assigned to you that ran the
release routine) — comments + linked issues + recent issues in the company
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
not invented ones.
3. Re-read the three verbatim examples below — they're the canonical voice.
4. Draft the announcement using the template above.
5. PUT it as the `discord_announcement` document on the release issue (see
"Output" above). If updating, send the latest `baseRevisionId`.
6. Post a comment on the release issue that includes the announcement inside a
single fenced markdown code block, so dotta can copy-paste it into Discord
without opening the document.
Do not publish to Discord. This skill only prepares the artifact.
## Verbatim previous examples
Three previous Discord announcements from dotta, included **verbatim** as the
ground-truth examples for voice, structure, and emoji usage. When in doubt,
match these.
### Example 1 — v2026.403.0
```
CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip:
## Highlights
:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work
:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning
:page_with_curl: **Document revisions** - you can now restore old versions of your documents
:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables
:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings
:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy
PLUS **tons** of improvements aound adapters, bugfixes, qol
## COMMUNITY
HUGE THANKS to the contributors with commits in this release:
```
@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai
```
## WHATS NEXT (ROADMAP)
* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly
* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider
PLUS: just dealing with the excellent PRs we have sitting in our inbox.
**What's also on my mind (coming soonish)**
* MAXIMIZER MODE - for when you've got a dream and tokens to burn
* Artifacts, work products, and deployments
* CEO Chat
* Stronger agent defaults
## PRESS
I've been doing my part to spread the word about Paperclip
* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407)
* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345)
* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377)
* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613)
* ... and a couple others that will be released in a few days
## SUCCESS STORIES
* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing
* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment.
* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is.
* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it.
* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works.
## IN SUMMARY
I know there are still some rough edges, but
Paperclip will be *the* control plane for AI agents in **every** company.
and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES HERE
https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0
||@everyone||
```
### Example 2 — v2026.416.0
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip:
## Highlights
This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer
:thread: Issue chat threads now are a conversation more than comments
:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task)
:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip
:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work
And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0)
## COMMUNITY
INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release:
```
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6
```
## WHATS NEXT (:motorway: Roadmap)
* **Multi-human users** - in the last stages of testing, Paperclip is better with teams
* **Memory Infrastructure** - your agents will remember everything about yoru business
* **Sandbox execution** - run your agents anywhere
## What's on my mind
* I want to meet with companies who are using Paperclip in their business - if that's you let me know
* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already!
* We still need to get better at reviewing your PRs and we're improving our process every day
* "Zero-human company" language has to go - we're the human control plane for ai labor
* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon.
## PRESS
* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931)
* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) !
## IN SUMMARY
PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY
If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0
||@everyone||
```
### Example 3 — v2026.427.0
```
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip:
THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping
## Highlights
:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance
:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you
:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning
:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads
:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers
... and *tons* of other improvements and bugfixes.
## Community
Thank you to everyone who contributed to this release!
```
@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche
```
## WHATS NEXT (:motorway: Roadmap)
* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done
* **Secrets Service v2** - an important prereq for Paperclip cloud
* **Artifacts, memory, and knowledge**
* **Conference Room** aka CEO/Agent Chat
## What's on my mind
* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these
* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed
* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing
* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team
## Press
I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998)
## WHAT I NEED FROM YOU
FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others
## In Summary
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all.
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
FULL RELEASE NOTES
https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
||@everyone||
```
## Review checklist
Before handing off:
1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly.
2. Contributor list matches the changelog (same exclusions: bots, founders).
3. Highlights are a subset of the changelog Highlights — same shipped features,
not invented or pre-alpha work.
4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues /
active goals — not invented themes.
5. Section style (UPPERCASE vs Title Case) is internally consistent.
6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:`
and `||@everyone||` is the very last line.
7. Document `discord_announcement` is updated on the release issue, and the
announcement is also posted in a comment inside a fenced code block.
This skill never posts to Discord. It only prepares the announcement artifact.
+77
View File
@@ -0,0 +1,77 @@
name: "Build: Dev"
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image-tag: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
id: tag
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip-dev
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
update-infra:
needs: build
runs-on: ubuntu-latest
steps:
- name: Update dev image tag in infra repo
run: |
SHA="${{ needs.build.outputs.image-tag }}"
FILE="overlays/dev/kustomization.yaml"
response=$(curl -sS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
file_sha=$(echo "$response" | jq -r '.sha')
content=$(echo "$response" | jq -r '.content' | base64 -d)
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
encoded=$(printf '%s' "$new_content" | base64 -w 0)
curl -sS -X PUT \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
+48
View File
@@ -0,0 +1,48 @@
name: "Build: Production"
on:
push:
branches: [local]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
+3 -1
View File
@@ -29,9 +29,11 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npx playwright install --with-deps chromium
- run: google-chrome --version
- name: Run e2e tests
env:
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- uses: actions/upload-artifact@v4
+94 -6
View File
@@ -45,6 +45,12 @@ jobs:
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
@@ -62,7 +68,8 @@ jobs:
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
fi
verify:
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -88,12 +95,89 @@ jobs:
- name: Typecheck workspaces whose build scripts skip TypeScript
run: pnpm run typecheck:build-gaps
- name: Run general test suites
run: pnpm test:run:general
- name: Verify release registry test coverage
run: pnpm run test:release-registry
general_tests:
name: General tests (${{ matrix.group_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- group: general-server
group_label: server
- group: general-workspaces-a
group_label: workspaces-a
- group: general-workspaces-b
group_label: workspaces-b
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run grouped general test suites
run: pnpm test:run:general -- --group '${{ matrix.group }}'
verify:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
if: ${{ always() }}
needs: [typecheck_release_registry, general_tests, build]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail if any split verify lane failed
env:
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
BUILD_RESULT: ${{ needs.build.result }}
run: |
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$BUILD_RESULT" = "success"
build:
name: Build
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
@@ -195,8 +279,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Verify runner Chrome
# GitHub's Ubuntu runner image already ships Google Chrome, so use that
# directly for the headless e2e lane instead of downloading Playwright
# browser bundles inside the 30 minute job budget.
run: google-chrome --version
- name: Generate Paperclip config
run: |
@@ -216,6 +303,7 @@ jobs:
- name: Run e2e tests
env:
PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
- name: Upload Playwright report
+5 -2
View File
@@ -58,8 +58,10 @@ jobs:
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Verify runner Chrome
# Release smoke also runs headless on GitHub's Ubuntu image, so use the
# runner's preinstalled Chrome instead of a Playwright browser download.
run: google-chrome --version
- name: Launch Docker smoke harness
run: |
@@ -89,6 +91,7 @@ jobs:
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:release-smoke
- name: Capture Docker logs
+2
View File
@@ -55,4 +55,6 @@ tests/e2e/playwright-report/
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
.superset/
.superpowers/
.claude/worktrees/
.herenow
+43
View File
@@ -0,0 +1,43 @@
# Paperclip fork — farhoodlabs
This is a thin fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
Fork repo: https://git.farh.net/farhoodlabs/paperclip
## Branch model
| Branch | Purpose |
|---|---|
| `master` | Pure mirror of `upstream/master`. No fork files. Sync via `git push origin upstream/master:master --force-with-lease`. |
| `dev` | `master` + one fork commit (Dockerfile prod stage + 2 build workflows). Builds `git.farh.net/farhoodlabs/paperclip-dev:*` on push. |
| `local` | **Deployed branch.** Same content as `dev`. Builds `git.farh.net/farhoodlabs/paperclip:*` on push. |
The fork tree differs from upstream by exactly **3 files**:
```
Dockerfile (production stage adds kubectl, kubeseal, uv, forgejo CLIs, tea, mmx-cli, nano, vim)
.github/workflows/build-prod.yml (pushes to git.farh.net/farhoodlabs/paperclip)
.github/workflows/build-dev.yml (pushes to git.farh.net/farhoodlabs/paperclip-dev)
```
The base/deps/build stages of the Dockerfile match upstream verbatim so upstream changes apply cleanly.
## Sync upstream
```bash
git fetch upstream
git push origin upstream/master:master --force-with-lease
git checkout dev && git merge master && git push origin dev
git checkout local && git merge dev && git push origin local
```
Conflicts should only ever appear on `Dockerfile` itself (if upstream changes the production stage). Resolution rule: keep upstream's deps/base/build stages exactly; preserve the fork's `RUN` block in the production stage.
## Deployment
Production runs in Kubernetes (`paperclip` namespace, single replica). Image: `git.farh.net/farhoodlabs/paperclip:<tag>`. Flux does not watch moving tags — rolling a fix means either pushing a semver-tagged release or `kubectl rollout restart deploy/paperclip -n paperclip`.
## Don't
- **Don't add fork code changes.** This fork is intentionally minimal after the 2026-05-31 reset (event-loop starvation bug from accumulated drift). If a feature is missing relative to a prior fork iteration (Gitea-hosted skills, PAT support for private skill repos, secret export/import, k8s sandbox-provider plugin, agentId threading), surface the regression — don't pull it back from `git log` without explicit go-ahead.
- **Don't commit to `local` without going through `dev` first** (and through `master` for upstream syncs). The promotion order is enforced.
- **Don't recreate `.farhoodlabs/` overlay or `assemble-local.yml`.** That model was retired.
+26 -3
View File
@@ -22,12 +22,14 @@ COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/grok-local/package.json packages/adapters/grok-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/
@@ -35,7 +37,9 @@ COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
COPY patches/ patches/
COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
@@ -53,10 +57,29 @@ 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 \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools, mmx-cli
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl \
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& mv /root/.local/bin/uv /usr/local/bin/uv \
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj \
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj-ex \
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
&& chmod +x /usr/local/bin/fgj \
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
&& chmod +x /usr/local/bin/tea \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& npm install --global --omit=dev mmx-cli \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
+13 -20
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
<img src="doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
</p>
<p align="center">
@@ -7,7 +7,8 @@
<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> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a>
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
<a href="https://paperclip.ing"><strong>Website</strong></a>
</p>
<p align="center">
@@ -24,15 +25,15 @@
<br/>
## What is Paperclip?
# Paperclip is the app people use to manage AI agents for work.
# Open-source orchestration for zero-human companies
Open-source orchestration for teams of AI agents.
**If OpenClaw is an _employee_, Paperclip is the _company_**
**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.
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 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.
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.**
@@ -44,10 +45,6 @@ It looks like a task manager — but under the hood it has org charts, budgets,
<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>
@@ -113,7 +110,7 @@ Every conversation traced. Every decision explained. Full tool-call tracing and
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
@@ -222,7 +219,7 @@ Paperclip is a full control plane, not a wrapper. Before you build any of this y
</td>
<td>
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. You're the board — nothing ships without your sign-off.
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off.
</td>
</tr>
@@ -317,7 +314,7 @@ This starts the API server at `http://localhost:3100`. An embedded PostgreSQL da
**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.
If you're a solo entrepreneur 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.
@@ -418,7 +415,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
## License
MIT &copy; 2026 Paperclip
MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
## Star History
@@ -429,9 +426,5 @@ MIT &copy; 2026 Paperclip
---
<p align="center">
<img src="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>
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
</p>
+15
View File
@@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
<br/>
## Paperclip Cloud Sync
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
```bash
paperclipai cloud connect https://your-stack.paperclip.app
paperclipai cloud connect https://your-stack.paperclip.app --no-browser
paperclipai cloud push --company <local-company-id> --dry-run
paperclipai cloud push --company <local-company-id>
```
`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
## Development
```bash
+1
View File
@@ -43,6 +43,7 @@
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
+243
View File
@@ -0,0 +1,243 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import {
assertDiscoveryCompatible,
buildBundleFromLocalCompany,
cloudCommandExitCodes,
connectCloud,
resolveDeviceCodeExpiresAt,
} from "../commands/client/cloud.js";
import {
LocalUpstreamPushCoordinator,
normalizedContentHash,
type LocalUpstreamExportBundle,
} from "../commands/client/cloud-transfer.js";
import { getCloudConnection } from "../commands/client/cloud-store.js";
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
describe("cloud CLI helpers", () => {
let tempHome: string;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
});
afterEach(() => {
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
fs.rmSync(tempHome, { recursive: true, force: true });
});
it("connects with the device-code flow and stores the resulting cloud connection", async () => {
globalThis.fetch = vi.fn(async (url, init) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
return jsonResponse(discovery());
}
if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
expect(JSON.parse(String(init?.body))).toMatchObject({
stackId: "stack-1",
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
});
return jsonResponse({
deviceCode: "device-1",
userCode: "ABCD-EFGH",
verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
intervalSeconds: 0,
});
}
if (requestUrl.endsWith("/api/upstream-sync/token")) {
return jsonResponse({
accessToken: "upt_test",
scopes: ["upstream_import:preview"],
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
});
}
return jsonResponse({ error: "not_found" }, 404);
}) as typeof fetch;
const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
expect(connection.accessToken).toBe("upt_test");
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
});
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
});
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
const validExpiry = "2026-05-22T13:05:00.000Z";
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
});
it("builds deterministic chunks with validated payload hashes", async () => {
const bundle = await buildTestBundle();
expect(bundle.chunks).toHaveLength(2);
expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
});
it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
const bundle = await buildTestBundle();
const calls: Array<{ path: string; body: unknown }> = [];
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: "https://cloud.example.test",
paperclipCompanyId: "target-company-1",
fetch: async (url, init) => {
const parsed = new URL(String(url));
const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
calls.push({ path: parsed.pathname, body });
if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
},
});
await coordinator.apply(bundle);
await coordinator.apply(bundle);
const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
expect(runBodies).toHaveLength(2);
expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
});
});
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
return buildBundleFromLocalCompany({
localCompanyId: "local-company-1",
connection: {
id: "conn-1",
remoteUrl: "https://cloud.example.test",
targetOrigin: "https://cloud.example.test",
targetHost: "cloud.example.test",
stackId: "stack-1",
targetCompanyId: "target-company-1",
accessToken: "upt_test",
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
privateKeyPem: "unused",
sourcePublicKey: "unused",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
},
discovery: discovery(),
localApi: {
post: async <T>() => portabilityExport() as T,
},
maxEntitiesPerChunk: 1,
mode: "apply",
});
}
function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
return {
schema: "paperclip-upstream-discovery-v1",
stack: {
id: "stack-1",
slug: "cloud-test",
displayName: "Cloud Test",
companyId: "target-company-1",
origin: "https://cloud.example.test",
},
auth: {
deviceCode: {
deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
},
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
},
transfer: {
supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
featureFlags: ["cloud_sync"],
},
};
}
function portabilityExport(): CompanyPortabilityExportResult {
return {
rootPath: ".",
paperclipExtensionPath: ".paperclip.yaml",
manifest: {
schemaVersion: 1,
generatedAt: "2026-05-18T00:00:00.000Z",
source: {
companyId: "local-company-1",
companyName: "Local Company",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "company.json",
name: "Local Company",
description: null,
brandColor: null,
logoPath: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: null,
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {
"README.md": "Local Company",
},
warnings: [],
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
+13 -6
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
import { buildPresetServerConfig } from "../config/server-bind.js";
const ORIGINAL_PATH = process.env.PATH;
describe("network bind helpers", () => {
it("rejects non-loopback bind modes in local_trusted", () => {
expect(
@@ -50,13 +52,18 @@ describe("network bind helpers", () => {
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
process.env.PATH = "";
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
try {
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
expect(preset.server.host).toBe("127.0.0.1");
expect(preset.server.host).toBe("127.0.0.1");
} finally {
process.env.PATH = ORIGINAL_PATH;
}
});
});
+7 -1
View File
@@ -7,6 +7,7 @@ import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_PATH = process.env.PATH;
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
@@ -171,8 +172,13 @@ describe("onboard", () => {
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
const configPath = createFreshConfigPath();
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
process.env.PATH = "";
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
try {
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
} finally {
process.env.PATH = ORIGINAL_PATH;
}
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
+506
View File
@@ -0,0 +1,506 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerSkillsCommands } from "../commands/client/skills.js";
import { resolveCompanySkillReference } from "../commands/client/skills.js";
const ORIGINAL_ENV = { ...process.env };
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerSkillsCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function skill(overrides: Record<string, unknown> = {}) {
return {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
key: "paperclip/review-prs",
slug: "review-prs",
name: "Review PRs",
description: "Review pull requests",
markdown: "# Review PRs",
sourceType: "local_path",
sourceLocator: null,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: null,
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
attachedAgentCount: 2,
editable: true,
editableReason: null,
sourceLabel: null,
sourceBadge: "local",
sourcePath: null,
...overrides,
};
}
function catalogSkill(overrides: Record<string, unknown> = {}) {
return {
id: "paperclipai:bundled:software-development:github-pr-workflow",
key: "paperclipai/bundled/software-development/github-pr-workflow",
kind: "bundled",
category: "software-development",
slug: "github-pr-workflow",
name: "github-pr-workflow",
description: "Prepare pull requests, review responses, and verification notes.",
path: "catalog/bundled/software-development/github-pr-workflow",
entrypoint: "SKILL.md",
trustLevel: "markdown_only",
compatibility: "compatible",
defaultInstall: false,
recommendedForRoles: ["engineer"],
requires: [],
tags: ["github", "pull-requests"],
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "sha256:abc" }],
contentHash: "sha256:catalog",
...overrides,
};
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
companyId: "company-1",
name: "Coder",
role: "engineer",
status: "active",
reportsTo: null,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
...overrides,
};
}
describe("skills CLI helpers", () => {
it("resolves skill refs by id, key, or unique normalized slug", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "alpha", name: "Alpha" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "beta-skill", name: "Beta" }),
];
expect(resolveCompanySkillReference(rows, "skill-a").key).toBe("paperclip/a");
expect(resolveCompanySkillReference(rows, "paperclip/b").id).toBe("skill-b");
expect(resolveCompanySkillReference(rows, "Beta Skill").id).toBe("skill-b");
});
it("rejects ambiguous slug refs", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "same", name: "A" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "same", name: "B" }),
];
expect(() => resolveCompanySkillReference(rows, "same")).toThrow(/Ambiguous skill slug/);
});
});
describe("skills CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let writeChunks: unknown[];
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
writeChunks = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
writeChunks.push(chunk);
return true;
});
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("lists company skills as JSON through the shared client context", async () => {
const rows = [skill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"list",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ authorization: "Bearer token" }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("resolves a skill slug before reading detail", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({ ...skill(), usedByAgents: [] }));
await runCommand([
"skills",
"show",
"Review PRs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111",
expect.objectContaining({ method: "GET" }),
);
});
it("prints skill files as raw pipeable content in human mode", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({
skillId: "11111111-1111-1111-1111-111111111111",
path: "SKILL.md",
kind: "skill",
content: "# Review PRs",
language: "markdown",
markdown: true,
editable: true,
}));
await runCommand([
"skills",
"file",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(logSpy).not.toHaveBeenCalled();
expect(writeChunks.join("")).toBe("# Review PRs\n");
});
it("browses catalog skills with filters in table output", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([catalogSkill()]));
await runCommand([
"skills",
"browse",
"--kind",
"bundled",
"--category",
"software-development",
"--query",
"github",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&category=software-development&q=github",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(rendered).toContain("id");
expect(rendered).toContain("paperclipai:bundled:software-development:github-pr-workflow");
expect(rendered).toContain("roles");
});
it("searches catalog skills as JSON", async () => {
const rows = [catalogSkill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"search",
"pull requests",
"--kind",
"bundled",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&q=pull+requests",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("inspects catalog skill detail by query ref so keys with slashes work", async () => {
const detail = catalogSkill();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
await runCommand([
"skills",
"inspect",
"paperclipai/bundled/software-development/github-pr-workflow",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fgithub-pr-workflow",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
});
it("installs catalog skills into the company library without agent sync", async () => {
const result = {
action: "created",
skill: skill({
key: "paperclipai/bundled/software-development/github-pr-workflow",
slug: "pr-flow",
sourceType: "catalog",
}),
catalogSkill: catalogSkill(),
warnings: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
await runCommand([
"skills",
"install",
"github-pr-workflow",
"--as",
"pr-flow",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills/install-catalog",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
catalogSkillId: "github-pr-workflow",
slug: "pr-flow",
force: true,
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("passes force to skill updates", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceRef: "sha256:new" })));
await runCommand([
"skills",
"update",
"review-prs",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/install-update",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("audits installed skill bytes through the server", async () => {
const audit = {
skillId: "11111111-1111-1111-1111-111111111111",
installedHash: "sha256:installed",
originHash: "sha256:origin",
verdict: "warning",
codes: ["network_reference"],
findings: [{
code: "network_reference",
severity: "warning",
message: "Skill content references network-capable commands or URLs.",
path: "SKILL.md",
}],
scannedAt: "2026-05-26T00:00:00.000Z",
scanVersion: "skills-audit-v1",
};
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(audit));
await runCommand([
"skills",
"audit",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/audit",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(audit);
});
it("requires confirmation for reset and sends force when confirmed", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill({ sourceType: "catalog" })]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceType: "catalog" })));
await runCommand([
"skills",
"reset",
"review-prs",
"--yes",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/reset",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("syncs desired company skill refs to an agent and returns the runtime snapshot", async () => {
const snapshot = {
adapterType: "codex_local",
supported: true,
mode: "persistent",
desiredSkills: ["paperclip/review-prs"],
entries: [
{
key: "paperclip/review-prs",
runtimeName: "review-prs",
desired: true,
managed: true,
required: false,
state: "installed",
origin: "company_managed",
detail: null,
},
],
warnings: [],
};
fetchMock
.mockResolvedValueOnce(jsonResponse(agent()))
.mockResolvedValueOnce(jsonResponse(snapshot));
await runCommand([
"skills",
"agent",
"sync",
"coder",
"--skill",
"review-prs",
"--skill",
"paperclip/qa",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/agents/coder?companyId=company-1",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/agents/agent-1/skills/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"] }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(snapshot);
});
});
+39
View File
@@ -512,6 +512,45 @@ describe("worktree helpers", () => {
}
});
it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
try {
fs.mkdirSync(repoRoot, { recursive: true });
const repoConfigDir = path.join(repoRoot, ".paperclip");
fs.mkdirSync(repoConfigDir, { recursive: true });
fs.writeFileSync(path.join(repoConfigDir, "config.json"), "stale", "utf8");
fs.writeFileSync(path.join(repoConfigDir, ".env"), "STALE=1", "utf8");
// Simulate the repo-managed worktrees subfolder that holds every
// worktree checkout (the directory PAPA-358 reported as nuked).
const worktreesDir = path.join(repoConfigDir, "worktrees");
const checkoutDir = path.join(worktreesDir, "PAP-100-feature");
fs.mkdirSync(checkoutDir, { recursive: true });
const sentinelPath = path.join(checkoutDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "do-not-delete", "utf8");
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
force: true,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: path.join(tempRoot, ".paperclip-worktrees"),
});
expect(fs.existsSync(sentinelPath)).toBe(true);
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do-not-delete");
expect(fs.existsSync(path.join(repoConfigDir, "config.json"))).toBe(true);
expect(fs.readFileSync(path.join(repoConfigDir, "config.json"), "utf8")).not.toBe("stale");
} finally {
process.chdir(originalCwd);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
itEmbeddedPostgres(
"seeds authenticated users into minimally cloned worktree instances",
async () => {
+7
View File
@@ -5,6 +5,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
@@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printGeminiStreamEvent,
};
const grokLocalCLIAdapter: CLIAdapterModule = {
type: "grok_local",
formatStdoutEvent: printGrokStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
@@ -66,6 +72,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
cursorLocalCLIAdapter,
cursorCloudCLIAdapter,
geminiLocalCLIAdapter,
grokLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
+177
View File
@@ -0,0 +1,177 @@
import fs from "node:fs";
import path from "node:path";
import { resolvePaperclipInstanceRoot } from "../../config/home.js";
export interface CloudConnectionTokenRecord {
id: string;
companyStackId: string;
targetOrigin: string;
sourceInstanceId: string;
sourceInstanceFingerprint: string;
scopes: string[];
expiresAt: string;
[key: string]: unknown;
}
export interface CloudConnection {
id: string;
remoteUrl: string;
targetOrigin: string;
targetHost: string;
stackId: string;
stackSlug?: string | null;
stackDisplayName?: string | null;
targetCompanyId: string;
accessToken: string;
token: CloudConnectionTokenRecord;
privateKeyPem: string;
sourcePublicKey: string;
sourceInstanceId: string;
sourceInstanceFingerprint: string;
scopes: string[];
createdAt: string;
updatedAt: string;
}
interface CloudConnectionStore {
version: 1;
connections: Record<string, CloudConnection>;
currentConnectionId?: string;
}
function defaultStore(): CloudConnectionStore {
return {
version: 1,
connections: {},
};
}
export function resolveCloudConnectionStorePath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json");
}
export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore {
if (!fs.existsSync(storePath)) return defaultStore();
const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial<CloudConnectionStore> | null;
const connections: Record<string, CloudConnection> = {};
if (raw?.connections && typeof raw.connections === "object") {
for (const [id, value] of Object.entries(raw.connections)) {
const normalized = normalizeConnection(value);
if (normalized) connections[id] = normalized;
}
}
const currentConnectionId =
typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId]
? raw.currentConnectionId
: Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id;
return {
version: 1,
connections,
currentConnectionId,
};
}
export function writeCloudConnectionStore(
store: CloudConnectionStore,
storePath = resolveCloudConnectionStorePath(),
): void {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
}
export function upsertCloudConnection(
connection: CloudConnection,
storePath = resolveCloudConnectionStorePath(),
): CloudConnection {
const store = readCloudConnectionStore(storePath);
const existing = store.connections[connection.id];
const now = new Date().toISOString();
const next = {
...connection,
createdAt: existing?.createdAt ?? connection.createdAt ?? now,
updatedAt: now,
};
store.connections[next.id] = next;
store.currentConnectionId = next.id;
writeCloudConnectionStore(store, storePath);
return next;
}
export function getCloudConnection(
remoteUrlOrOrigin?: string,
storePath = resolveCloudConnectionStorePath(),
): CloudConnection | null {
const store = readCloudConnectionStore(storePath);
if (remoteUrlOrOrigin?.trim()) {
const needle = normalizeRemoteLookup(remoteUrlOrOrigin);
return Object.values(store.connections).find((connection) =>
normalizeRemoteLookup(connection.remoteUrl) === needle ||
normalizeRemoteLookup(connection.targetOrigin) === needle
) ?? null;
}
return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null;
}
function normalizeRemoteLookup(value: string): string {
try {
const url = new URL(value);
return url.origin.replace(/\/+$/u, "");
} catch {
return value.trim().replace(/\/+$/u, "");
}
}
function normalizeConnection(value: unknown): CloudConnection | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const id = stringValue(record.id);
const remoteUrl = stringValue(record.remoteUrl);
const targetOrigin = stringValue(record.targetOrigin);
const targetHost = stringValue(record.targetHost);
const stackId = stringValue(record.stackId);
const targetCompanyId = stringValue(record.targetCompanyId);
const accessToken = stringValue(record.accessToken);
const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
? record.token as CloudConnectionTokenRecord
: null;
const privateKeyPem = stringValue(record.privateKeyPem);
const sourcePublicKey = stringValue(record.sourcePublicKey);
const sourceInstanceId = stringValue(record.sourceInstanceId);
const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
const createdAt = stringValue(record.createdAt);
const updatedAt = stringValue(record.updatedAt);
if (
!id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId ||
!accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId ||
!sourceInstanceFingerprint || !createdAt || !updatedAt
) {
return null;
}
return {
id,
remoteUrl,
targetOrigin,
targetHost,
stackId,
stackSlug: stringValue(record.stackSlug),
stackDisplayName: stringValue(record.stackDisplayName),
targetCompanyId,
accessToken,
token,
privateKeyPem,
sourcePublicKey,
sourceInstanceId,
sourceInstanceFingerprint,
scopes: stringArray(record.scopes),
createdAt,
updatedAt,
};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
}
+297
View File
@@ -0,0 +1,297 @@
import { createHash } from "node:crypto";
export const upstreamTransferSchema = {
family: "paperclip-upstream-transfer",
version: "1.0.0",
major: 1,
minor: 0,
} as const;
export type NormalizedSha256 = `sha256:${string}`;
export interface SourceEntityKey {
sourceInstanceId: string;
sourceCompanyId: string;
sourceEntityType: string;
sourceEntityId: string;
sourceNaturalKey?: string;
}
export interface UpstreamTransferWarning {
code: string;
severity: "info" | "warning" | "blocker";
message: string;
entity?: SourceEntityKey;
}
export interface UpstreamTransferEntityRecord {
key: SourceEntityKey;
contentHash: NormalizedSha256;
dependencies: SourceEntityKey[];
warnings: UpstreamTransferWarning[];
}
export interface UpstreamTransferManifestSource {
sourceInstanceId: string;
sourceCompanyId: string;
sourceInstanceKeyFingerprint: string;
exporterVersion: string;
sourceSchemaVersion: string;
}
export interface UpstreamTransferManifestTarget {
targetStackId: string;
targetCompanyId: string;
targetOrigin: string;
supportedSchemaMajor: number;
}
export interface UpstreamTransferChunk {
chunkIndex: number;
totalChunks: number;
byteLength: number;
sha256: NormalizedSha256;
manifestHash: NormalizedSha256;
}
export interface UpstreamTransferManifest {
schema: typeof upstreamTransferSchema;
source: UpstreamTransferManifestSource;
target: UpstreamTransferManifestTarget;
runId: string;
idempotencyKey: string;
generatedAt: string;
entityCount: number;
entities: UpstreamTransferEntityRecord[];
chunks: UpstreamTransferChunk[];
warnings: UpstreamTransferWarning[];
featureFlags: string[];
manifestHash: NormalizedSha256;
}
export interface LocalUpstreamExportEntityInput {
key: SourceEntityKey;
body: Record<string, unknown>;
dependencies?: SourceEntityKey[];
warnings?: UpstreamTransferWarning[];
conflictKeys?: string[];
}
export interface LocalUpstreamExportEntity {
record: UpstreamTransferEntityRecord;
body: Record<string, unknown>;
conflictKeys?: string[];
}
export interface LocalUpstreamExportChunk {
chunkIndex: number;
totalChunks: number;
byteLength: number;
sha256: NormalizedSha256;
payload: {
entityKeys: SourceEntityKey[];
};
}
export interface LocalUpstreamExportBundle {
manifest: UpstreamTransferManifest;
entities: LocalUpstreamExportEntity[];
chunks: LocalUpstreamExportChunk[];
}
export interface BuildLocalUpstreamExportBundleInput {
source: UpstreamTransferManifestSource;
target: UpstreamTransferManifestTarget;
runId: string;
idempotencyKey: string;
entities: LocalUpstreamExportEntityInput[];
warnings?: UpstreamTransferWarning[];
featureFlags?: string[];
maxEntitiesPerChunk?: number;
}
export interface LocalUpstreamPushCoordinatorOptions {
targetOrigin: string;
paperclipCompanyId: string;
fetch?: typeof fetch;
headers?: (input: { method: string; path: string }) => HeadersInit | Promise<HeadersInit>;
}
export class UpstreamImportRequestError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
export class LocalUpstreamPushCoordinator {
readonly #targetOrigin: string;
readonly #paperclipCompanyId: string;
readonly #fetch: typeof fetch;
readonly #headers: NonNullable<LocalUpstreamPushCoordinatorOptions["headers"]>;
constructor(options: LocalUpstreamPushCoordinatorOptions) {
this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, "");
this.#paperclipCompanyId = options.paperclipCompanyId;
this.#fetch = options.fetch ?? fetch;
this.#headers = options.headers ?? (() => ({}));
}
async preview(bundle: LocalUpstreamExportBundle): Promise<unknown> {
return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
manifest: bundle.manifest,
entities: bundle.entities,
});
}
async apply(bundle: LocalUpstreamExportBundle): Promise<unknown> {
const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, {
mode: "apply",
manifest: bundle.manifest,
entities: bundle.entities,
}) as { run?: { id?: unknown } };
const runId = typeof run.run?.id === "string" ? run.run.id : undefined;
if (!runId) {
throw new Error("Remote upstream importer did not return a run id");
}
for (const chunk of bundle.chunks) {
await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk);
}
return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {});
}
async events(runId: string): Promise<unknown> {
return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`);
}
private async get(path: string): Promise<unknown> {
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
method: "GET",
headers: await this.#headers({ method: "GET", path }),
});
return parseCoordinatorResponse(response);
}
private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(await this.#headers({ method: "POST", path })),
},
body: JSON.stringify(body),
});
return parseCoordinatorResponse(response);
}
}
export function buildLocalUpstreamExportBundle(
input: BuildLocalUpstreamExportBundleInput,
): LocalUpstreamExportBundle {
const entities = input.entities.map<LocalUpstreamExportEntity>((entity) => ({
record: {
key: entity.key,
contentHash: normalizedContentHash(entity.body),
dependencies: entity.dependencies ?? [],
warnings: entity.warnings ?? [],
},
body: entity.body,
conflictKeys: entity.conflictKeys,
}));
const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100);
const manifestWithoutHash = {
schema: upstreamTransferSchema,
source: input.source,
target: input.target,
runId: input.runId,
idempotencyKey: input.idempotencyKey,
generatedAt: new Date(0).toISOString(),
entityCount: entities.length,
entities: entities.map((entity) => entity.record),
chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
warnings: input.warnings ?? [],
featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
};
const manifestHash = normalizedContentHash(manifestWithoutHash);
return {
manifest: {
...manifestWithoutHash,
chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })),
manifestHash,
},
entities,
chunks,
};
}
export function normalizedContentHash(value: unknown): NormalizedSha256 {
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
}
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortJson(value));
}
function buildLocalChunks(
entities: LocalUpstreamExportEntity[],
maxEntitiesPerChunk: number,
): LocalUpstreamExportChunk[] {
if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
throw new Error("maxEntitiesPerChunk must be a positive integer");
}
if (entities.length === 0) return [];
const groups: LocalUpstreamExportEntity[][] = [];
for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
groups.push(entities.slice(index, index + maxEntitiesPerChunk));
}
return groups.map((group, index) => {
const payload = {
entityKeys: group.map((entity) => entity.record.key),
};
return {
chunkIndex: index,
totalChunks: groups.length,
byteLength: Buffer.byteLength(canonicalJson(payload)),
sha256: normalizedContentHash(payload),
payload,
};
});
}
function sortJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortJson);
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, sortJson(entry)]),
);
}
async function parseCoordinatorResponse(response: Response): Promise<unknown> {
const text = await response.text();
const parsed = text.trim() ? safeParseJson(text) : {};
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
? String((parsed as { error: unknown }).error)
: `Upstream importer request failed with ${response.status}`;
throw new UpstreamImportRequestError(response.status, message, parsed);
}
return parsed;
}
function safeParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+721
View File
@@ -0,0 +1,721 @@
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
import { createServer, type Server } from "node:http";
import { URL } from "node:url";
import { Command } from "commander";
import pc from "picocolors";
import type {
CompanyPortabilityExportResult,
CompanyPortabilityFileEntry,
InstanceExperimentalSettings,
} from "@paperclipai/shared";
import { openUrl } from "../../client/board-auth.js";
import { resolvePaperclipInstanceId } from "../../config/home.js";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
import {
buildLocalUpstreamExportBundle,
LocalUpstreamPushCoordinator,
normalizedContentHash,
upstreamTransferSchema,
UpstreamImportRequestError,
type LocalUpstreamExportBundle,
type LocalUpstreamExportEntityInput,
type SourceEntityKey,
type UpstreamTransferManifestSource,
type UpstreamTransferManifestTarget,
type UpstreamTransferWarning,
} from "./cloud-transfer.js";
import {
getCloudConnection,
upsertCloudConnection,
type CloudConnection,
type CloudConnectionTokenRecord,
} from "./cloud-store.js";
const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
interface CloudConnectOptions extends BaseClientOptions {
noBrowser?: boolean;
}
interface CloudPushOptions extends BaseClientOptions {
company?: string;
remoteUrl?: string;
dryRun?: boolean;
maxEntitiesPerChunk?: number;
}
interface UpstreamDiscovery {
schema: string;
stack: {
id: string;
slug?: string;
displayName?: string;
companyId: string;
origin: string;
};
auth: {
pkce?: {
authorizeUrl: string;
tokenUrl: string;
codeChallengeMethod: string;
};
deviceCode?: {
deviceCodeUrl: string;
verificationUrl: string;
tokenUrl: string;
};
scopes?: string[];
};
transfer: {
supportedSchemaMajor: number;
featureFlags?: string[];
};
}
interface TokenResponse {
accessToken: string;
token: CloudConnectionTokenRecord;
scopes?: string[];
expiresAt?: string;
}
class CloudAuthRequestError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
export function registerCloudCommands(program: Command): void {
const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands");
addCommonClientOptions(
cloud
.command("connect")
.description("Authorize this local instance to push into a Paperclip Cloud stack")
.argument("<remote-url>", "Paperclip Cloud stack URL")
.option("--no-browser", "Use the device-code flow instead of opening a browser", false)
.action(async (remoteUrl: string, opts: CloudConnectOptions) => {
try {
await connectCloud(remoteUrl, opts);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
cloud
.command("push")
.description("Preview or apply a local company push into the connected Paperclip Cloud stack")
.requiredOption("--company <local-company-id>", "Local company ID to export")
.option("--remote-url <remote-url>", "Use a specific stored cloud connection")
.option("--dry-run", "Preview without applying", false)
.option("--max-entities-per-chunk <count>", "Chunk size for upstream uploads", (value) => Number(value), 100)
.action(async (opts: CloudPushOptions) => {
try {
await pushCloud(opts);
} catch (err) {
if (isSchemaMismatchError(err)) {
console.error(pc.red(err instanceof Error ? err.message : String(err)));
process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE;
return;
}
handleCommandError(err);
}
}),
);
}
export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise<CloudConnection> {
const ctx = resolveCommandContext(opts);
const discovery = await discoverUpstream(remoteUrl);
assertDiscoveryCompatible(discovery);
const source = createSourceIdentity();
const token = await authorizeConnection(discovery, source, {
noBrowser: Boolean(opts.noBrowser),
});
const targetOrigin = discovery.stack.origin.replace(/\/+$/u, "");
const targetHost = new URL(targetOrigin).host;
const now = new Date().toISOString();
const connection = upsertCloudConnection({
id: connectionId(targetOrigin),
remoteUrl,
targetOrigin,
targetHost,
stackId: discovery.stack.id,
stackSlug: discovery.stack.slug ?? null,
stackDisplayName: discovery.stack.displayName ?? null,
targetCompanyId: discovery.stack.companyId,
accessToken: token.accessToken,
token: token.token,
privateKeyPem: source.privateKeyPem,
sourcePublicKey: source.sourcePublicKey,
sourceInstanceId: source.sourceInstanceId,
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES,
createdAt: now,
updatedAt: now,
});
if (ctx.json) {
printOutput(redactConnection(connection), { json: true });
} else {
console.log(pc.bold("Connected to Paperclip Cloud"));
console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`);
console.log(`origin=${connection.targetOrigin}`);
console.log(`company=${connection.targetCompanyId}`);
}
return connection;
}
export async function pushCloud(opts: CloudPushOptions): Promise<unknown> {
const ctx = resolveCommandContext(opts, { requireCompany: false });
const localCompanyId = requiredString(opts.company, "--company");
await assertCloudSyncEnabled(ctx.api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"));
const connection = getCloudConnection(opts.remoteUrl);
if (!connection) {
throw new Error("No cloud connection found. Run `paperclipai cloud connect <remote-url>` first.");
}
const discovery = await discoverUpstream(connection.targetOrigin);
assertDiscoveryCompatible(discovery);
const bundle = await buildBundleFromLocalCompany({
localCompanyId,
connection,
discovery,
localApi: ctx.api,
maxEntitiesPerChunk: opts.maxEntitiesPerChunk,
mode: opts.dryRun ? "preview" : "apply",
});
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: connection.targetOrigin,
paperclipCompanyId: connection.targetCompanyId,
headers: ({ method, path }) => cloudProofHeaders(connection, method, path),
});
const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle);
const runId = getRunId(result);
const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null;
const summary = summarizeResult(result);
const conflictCount = summary.conflict + summary.staleMapping;
if (ctx.json) {
printOutput({ result, events }, { json: true });
} else {
console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied"));
console.log(`run=${runId ?? "-"}`);
console.log(`manifest=${bundle.manifest.manifestHash}`);
console.log(
`create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` +
`skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`,
);
printWarnings(result);
printConflicts(result);
printEvents(events);
}
if (conflictCount > 0) {
process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE;
}
return result;
}
export async function discoverUpstream(remoteUrl: string): Promise<UpstreamDiscovery> {
const base = new URL(remoteUrl);
const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
return requestCloudJson<UpstreamDiscovery>(discoveryUrl.toString(), { method: "GET" });
}
export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
if (discovery.schema !== "paperclip-upstream-discovery-v1") {
throw new Error("Remote URL is not a Paperclip Cloud upstream target.");
}
if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) {
throw new Error(
`Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`,
);
}
if (!discovery.transfer.featureFlags?.includes("cloud_sync")) {
throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag.");
}
}
export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
}
export async function buildBundleFromLocalCompany(input: {
localCompanyId: string;
connection: CloudConnection;
discovery: UpstreamDiscovery;
localApi: {
post<T>(path: string, body?: unknown): Promise<T | null>;
};
maxEntitiesPerChunk?: number;
mode: "preview" | "apply";
}): Promise<LocalUpstreamExportBundle> {
const exported = await input.localApi.post<CompanyPortabilityExportResult>(
`/api/companies/${input.localCompanyId}/export`,
{
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
expandReferencedSkills: true,
},
);
if (!exported) throw new Error("Local company export returned no data.");
const sourceHash = normalizedContentHash({
manifest: exported.manifest,
files: exported.files,
});
const source: UpstreamTransferManifestSource = {
sourceInstanceId: input.connection.sourceInstanceId,
sourceCompanyId: input.localCompanyId,
sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint,
exporterVersion: "paperclipai-cli-cloud-v1",
sourceSchemaVersion: "paperclip-local-portability-v1",
};
const target: UpstreamTransferManifestTarget = {
targetStackId: input.discovery.stack.id,
targetCompanyId: input.discovery.stack.companyId,
targetOrigin: input.discovery.stack.origin,
supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor,
};
const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported);
const idempotencyKey = [
input.mode,
input.connection.sourceInstanceId,
input.localCompanyId,
input.discovery.stack.id,
sourceHash,
].join(":");
return buildLocalUpstreamExportBundle({
source,
target,
runId: `local-${input.mode}-${shortHash(idempotencyKey)}`,
idempotencyKey,
entities,
warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
code: "local_company_export_warning",
severity: "warning",
message,
})),
featureFlags: ["cloud_sync"],
maxEntitiesPerChunk: input.maxEntitiesPerChunk,
});
}
async function authorizeConnection(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
opts: { noBrowser: boolean },
): Promise<TokenResponse> {
if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) {
try {
return await authorizeWithBrowser(discovery, source);
} catch (error) {
console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`));
}
}
if (!discovery.auth.deviceCode) {
throw new Error("Remote Paperclip Cloud stack does not support device-code authorization.");
}
return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() });
}
async function authorizeWithBrowser(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
): Promise<TokenResponse> {
const pkce = discovery.auth.pkce;
if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
const callback = await startPkceCallbackServer();
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomUUID();
const authorizeUrl = new URL(pkce.authorizeUrl);
authorizeUrl.searchParams.set("redirectUri", callback.redirectUri);
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("codeChallenge", challenge);
authorizeUrl.searchParams.set("codeChallengeMethod", "S256");
authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId);
authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint);
authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey);
authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" "));
try {
console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`);
if (!openUrl(authorizeUrl.toString())) {
throw new Error("Could not open a browser.");
}
const code = await callback.waitForCode(state);
return requestCloudJson<TokenResponse>(pkce.tokenUrl, {
method: "POST",
body: JSON.stringify({
grantType: "authorization_code",
code,
redirectUri: callback.redirectUri,
codeVerifier: verifier,
}),
});
} finally {
await callback.close();
}
}
async function authorizeWithDeviceCode(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
opts: { openBrowser: boolean },
): Promise<TokenResponse> {
const device = discovery.auth.deviceCode;
if (!device) throw new Error("Remote did not advertise device-code authorization.");
const response = await requestCloudJson<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt?: string;
intervalSeconds?: number;
}>(device.deviceCodeUrl, {
method: "POST",
body: JSON.stringify({
stackId: discovery.stack.id,
sourceInstanceId: source.sourceInstanceId,
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
sourcePublicKey: source.sourcePublicKey,
scopes: CLOUD_SYNC_SCOPES,
}),
});
console.error(pc.bold("Cloud device authorization required"));
console.error(`Open: ${response.verificationUri}`);
console.error(`Code: ${response.userCode}`);
if (opts.openBrowser) openUrl(response.verificationUri);
const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
while (Date.now() < expiresAt) {
await sleep(intervalMs);
try {
return await requestCloudJson<TokenResponse>(device.tokenUrl, {
method: "POST",
body: JSON.stringify({
grantType: "device_code",
deviceCode: response.deviceCode,
}),
});
} catch (error) {
if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") {
const code = (error.body as { error?: unknown }).error;
if (code === "authorization_pending") continue;
}
throw error;
}
}
throw new Error("Device-code authorization expired before it was approved.");
}
function buildEntitiesFromPortableExport(
localCompanyId: string,
sourceInstanceId: string,
exported: CompanyPortabilityExportResult,
): LocalUpstreamExportEntityInput[] {
const companyKey: SourceEntityKey = {
sourceInstanceId,
sourceCompanyId: localCompanyId,
sourceEntityType: "company",
sourceEntityId: localCompanyId,
sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
};
const entities: LocalUpstreamExportEntityInput[] = [
{
key: companyKey,
body: {
kind: "paperclip_company_portability_manifest",
manifest: exported.manifest,
rootPath: exported.rootPath,
paperclipExtensionPath: exported.paperclipExtensionPath,
fileCount: Object.keys(exported.files).length,
},
conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
},
];
for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
entities.push({
key: {
sourceInstanceId,
sourceCompanyId: localCompanyId,
sourceEntityType: "company_setting",
sourceEntityId: shortHash(filePath),
sourceNaturalKey: filePath,
},
body: {
kind: "paperclip_portable_file",
path: filePath,
entry: normalizePortableFileEntry(entry),
},
dependencies: [companyKey],
conflictKeys: [`portable_file:${filePath}`],
});
}
return entities;
}
function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record<string, unknown> {
if (typeof entry === "string") {
return { encoding: "utf8", data: entry };
}
return { ...entry };
}
async function assertCloudSyncEnabled(settingsPromise: Promise<InstanceExperimentalSettings | null>): Promise<void> {
const settings = await settingsPromise;
if (settings?.enableCloudSync !== true) {
throw new Error(
"Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.",
);
}
}
function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record<string, string> {
const timestamp = new Date().toISOString();
const nonce = randomUUID();
const payload = [
method,
connection.targetHost.toLowerCase(),
pathAndSearch,
connection.token.id,
connection.sourceInstanceId,
timestamp,
nonce,
].join("\n");
return {
Authorization: `Bearer ${connection.accessToken}`,
"X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
"X-Paperclip-Upstream-Proof-Timestamp": timestamp,
"X-Paperclip-Upstream-Proof-Nonce": nonce,
"X-Paperclip-Upstream-Proof-Signature": sign(
null,
Buffer.from(payload, "utf8"),
connection.privateKeyPem,
).toString("base64url"),
};
}
async function requestCloudJson<T>(url: string, init: RequestInit): Promise<T> {
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
if (init.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const response = await fetch(url, { ...init, headers });
const text = await response.text();
const parsed = text.trim() ? JSON.parse(text) as unknown : {};
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
? String((parsed as { error: unknown }).error)
: `Cloud request failed with ${response.status}`;
throw new CloudAuthRequestError(response.status, message, parsed);
}
return parsed as T;
}
function createSourceIdentity() {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
const sourceInstanceFingerprint = `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
return {
sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`,
sourceInstanceFingerprint,
sourcePublicKey,
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
}
async function startPkceCallbackServer(): Promise<{
redirectUri: string;
waitForCode: (state: string) => Promise<string>;
close: () => Promise<void>;
}> {
let resolveCode: ((code: string) => void) | null = null;
let rejectCode: ((error: Error) => void) | null = null;
let expectedState = "";
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Paperclip Cloud authorization failed. You can close this tab.");
rejectCode?.(new Error("Authorization callback was missing a valid code or state."));
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Paperclip Cloud authorization complete. You can close this tab.");
resolveCode?.(code);
});
await listenOnLoopback(server);
const address = server.address();
if (typeof address !== "object" || !address?.port) {
throw new Error("Failed to start local authorization callback server.");
}
return {
redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`,
waitForCode: (state: string) => {
expectedState = state;
return codePromise;
},
close: () => closeServer(server),
};
}
function listenOnLoopback(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}
function canOpenBrowser(): boolean {
if (process.platform === "darwin" || process.platform === "win32") return true;
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}
function summarizeResult(result: unknown): {
create: number;
update: number;
adopt: number;
skip: number;
conflict: number;
staleMapping: number;
} {
const summary = asRecord(asRecord(result)?.summary);
return {
create: numberValue(summary?.create),
update: numberValue(summary?.update),
adopt: numberValue(summary?.adopt),
skip: numberValue(summary?.skip),
conflict: numberValue(summary?.conflict),
staleMapping: numberValue(summary?.staleMapping),
};
}
function printWarnings(result: unknown): void {
const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : [];
for (const warning of warnings) {
const record = asRecord(warning);
console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim()));
}
}
function printConflicts(result: unknown): void {
const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : [];
for (const conflict of conflicts.slice(0, 10)) {
const record = asRecord(conflict);
console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`));
}
if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`));
}
function printEvents(events: unknown): void {
const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : [];
for (const row of rows.slice(-10)) {
const event = asRecord(row);
console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`));
}
}
function getRunId(result: unknown): string | null {
const run = asRecord(asRecord(result)?.run);
return typeof run?.id === "string" ? run.id : null;
}
function redactConnection(connection: CloudConnection): Record<string, unknown> {
return {
id: connection.id,
remoteUrl: connection.remoteUrl,
targetOrigin: connection.targetOrigin,
stackId: connection.stackId,
targetCompanyId: connection.targetCompanyId,
scopes: connection.scopes,
expiresAt: connection.token.expiresAt,
};
}
function connectionId(targetOrigin: string): string {
return `cloud-${shortHash(targetOrigin)}`;
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function requiredString(value: unknown, label: string): string {
if (typeof value === "string" && value.trim()) return value.trim();
throw new Error(`${label} is required.`);
}
function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function isSchemaMismatchError(error: unknown): boolean {
if (error instanceof UpstreamImportRequestError) {
return JSON.stringify(error.body).toLowerCase().includes("schema");
}
return error instanceof Error && error.message.toLowerCase().includes("schema mismatch");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const cloudCommandExitCodes = {
conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE,
schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE,
} as const;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,7 @@ import {
createEmbeddedPostgresLogBuffer,
ensurePostgresDatabase,
formatEmbeddedPostgresError,
prepareEmbeddedPostgresNativeRuntime,
routines,
} from "@paperclipai/db";
import { eq, inArray } from "drizzle-orm";
@@ -116,6 +117,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
await prepareEmbeddedPostgresNativeRuntime();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
+8 -1
View File
@@ -45,6 +45,7 @@ import {
runDatabaseRestore,
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
prepareEmbeddedPostgresNativeRuntime,
} from "@paperclipai/db";
import type { Command } from "commander";
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
@@ -1059,6 +1060,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
await prepareEmbeddedPostgresNativeRuntime();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
@@ -1385,7 +1387,12 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
}
if (opts.force) {
rmSync(paths.repoConfigDir, { recursive: true, force: true });
// Only remove the specific files we're about to rewrite, not the whole
// repoConfigDir — that directory can contain sibling state such as
// <repo>/.paperclip/worktrees/ holding every repo-managed worktree
// checkout, and a recursive rmSync here would nuke them all.
rmSync(paths.configPath, { force: true });
rmSync(paths.envPath, { force: true });
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
+4
View File
@@ -19,6 +19,8 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { registerRoutineCommands } from "./commands/routines.js";
import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { registerSecretCommands } from "./commands/client/secrets.js";
import { registerCloudCommands } from "./commands/client/cloud.js";
import { registerSkillsCommands } from "./commands/client/skills.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
@@ -149,6 +151,8 @@ registerDashboardCommands(program);
registerRoutineCommands(program);
registerFeedbackCommands(program);
registerSecretCommands(program);
registerCloudCommands(program);
registerSkillsCommands(program);
registerWorktreeCommands(program);
registerEnvLabCommands(program);
registerPluginCommands(program);
+118
View File
@@ -143,6 +143,124 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
## Skills Commands
`paperclipai skills` covers three distinct operations:
1. **Company install** — adds or updates a row in `company_skills` for the
whole company. This is what `skills install`, `skills import`, `skills create`,
and `skills scan-projects` do.
2. **Agent attach** — replaces an agent's *desired* company skill set
(`skills agent sync`/`clear`). This is a desired-state operation on the
agent's adapter config; it does not change the company library.
3. **Adapter runtime sync** — the adapter reconciles the desired skill set
with files on disk and reports an `AgentSkillSnapshot` (`skills agent list`).
`skills agent sync` triggers this automatically after updating desired state.
Required Paperclip runtime skills (heartbeat, etc.) remain server-enforced and
are added on top of whatever the desired set names.
### Catalog (app-shipped skills)
The Paperclip app ships a curated catalog under `@paperclipai/skills-catalog`.
Browse and inspect commands never mutate company state; `install` adds a catalog
skill to the company library.
```sh
pnpm paperclipai skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]
pnpm paperclipai skills search "<text>" [--kind bundled|optional] [--category <slug>]
pnpm paperclipai skills inspect <catalog-id-or-key-or-slug>
pnpm paperclipai skills install <catalog-id-or-key-or-slug> [--as <slug>] [--force] --company-id <company-id>
```
Catalog semantics:
- **Bundled** skills live in `packages/skills-catalog/catalog/bundled/<category>/<slug>`
and are recommended defaults for most companies. They use canonical key
`paperclipai/bundled/<category>/<slug>`.
- **Optional** skills live in `packages/skills-catalog/catalog/optional/<category>/<slug>`
and are role-specific or domain-specific (browser, AWS ops, etc.). Same key
shape with `optional` in place of `bundled`.
- `skills install` materializes the catalog files into a company-managed skill
directory and records provenance (`catalogId`, `catalogKey`, `packageVersion`,
`originHash`, …) so future updates and audit decisions stay consistent.
- `--as <slug>` overrides the company skill slug. `--force` may replace a
same-key catalog-managed skill but never bypasses hard validation or hard-stop
audit findings.
Examples:
```sh
pnpm paperclipai skills browse --kind bundled --company-id <company-id>
pnpm paperclipai skills search "pull request" --kind bundled
pnpm paperclipai skills inspect github-pr-workflow
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm paperclipai skills install paperclipai:optional:browser:agent-browser --company-id <company-id>
```
External GitHub, skills.sh, local-path, and URL sources still go through
`skills import`; catalog commands are for the app-shipped catalog only.
### Company library
```sh
pnpm paperclipai skills list --company-id <company-id>
pnpm paperclipai skills show <skill-id-or-key-or-slug> --company-id <company-id>
pnpm paperclipai skills file <skill-id-or-key-or-slug> [--path SKILL.md] --company-id <company-id>
pnpm paperclipai skills import <source> --company-id <company-id>
pnpm paperclipai skills create --name "Review PRs" [--slug review-prs] [--description "..."] [--body-file SKILL.md] --company-id <company-id>
pnpm paperclipai skills scan-projects [--project-id <id>...] [--workspace-id <id>...] --company-id <company-id>
pnpm paperclipai skills check [skill-id-or-key-or-slug] --company-id <company-id>
pnpm paperclipai skills update <skill-id-or-key-or-slug> [--force] --company-id <company-id>
pnpm paperclipai skills update --all [--force] --company-id <company-id>
pnpm paperclipai skills audit [skill-id-or-key-or-slug] --company-id <company-id>
pnpm paperclipai skills reset <skill-id-or-key-or-slug> [--yes] [--force] --company-id <company-id>
pnpm paperclipai skills remove <skill-id-or-key-or-slug> --yes --company-id <company-id>
```
`skills import <source>` accepts a skills.sh URL, the equivalent
`<owner>/<repo>/<skill>` shorthand, a GitHub URL, a local path, or an
`npx skills add …` command. See `references/company-skills.md` in the agent
skill bundle for the source-type table.
`skills check`, `skills update`, `skills audit`, and `skills reset` are the
maintenance loop for catalog-installed skills:
- `check` reports whether each skill's installed bytes match its pinned origin
(`hasUpdate`, `installedHash`, `originHash`, `updateHoldReason`,
`auditVerdict`).
- `update` installs the pinned update through the existing install-update API.
`--all` checks every company skill and updates only those with
`hasUpdate=true`. `--force` discards local-modification or soft-audit holds;
hard-stop audit findings still block the update.
- `audit` re-scans installed bytes and reports findings without executing
anything.
- `reset` reinstalls a catalog-managed skill from its pinned origin, discarding
local edits. Prompts in a TTY; requires `--yes` for non-interactive use.
### Agent attach
```sh
pnpm paperclipai skills agent list <agent-id-or-shortname> --company-id <company-id>
pnpm paperclipai skills agent sync <agent-id-or-shortname> --skill <skill-id-or-key-or-slug> [--skill <skill-id-or-key-or-slug>...] --company-id <company-id>
pnpm paperclipai skills agent clear <agent-id-or-shortname> --yes --company-id <company-id>
```
`skills agent sync` replaces the agent's non-required desired skill set (it is
not additive) and returns the resulting adapter `AgentSkillSnapshot`.
`skills agent clear` sends an empty desired list. Required Paperclip skills are
still enforced by the server in both cases.
### Notes
- Skill references accept company skill `id`, canonical `key`, or unique
`slug`; catalog references accept catalog `id`, `key`, or unique `slug`.
- `skills file` prints raw file content in human mode so it can be piped.
- `skills create --body-file -` reads the skill markdown body from stdin.
- `skills remove`, `skills reset`, and `skills agent clear` prompt in a TTY and
require `--yes` in non-interactive use.
- `--json` prints the raw API result for each command.
## Secrets Commands
```sh
+15
View File
@@ -143,6 +143,17 @@ The database mode is controlled by `DATABASE_URL`:
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
## Resource membership tables
Paperclip stores current-user sidebar membership state in:
- `project_memberships`
- `agent_memberships`
These rows are company-scoped and user-scoped. A missing row means the user is joined, so existing users keep seeing projects and agents in the sidebar until they explicitly leave them. Rows only control sidebar visibility; they do not affect project/agent detail access, all-pages, selectors, assignment flows, or existing company permissions.
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
## Plugin database namespaces
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
@@ -165,6 +176,10 @@ Paperclip stores secret metadata and versions in:
- `company_secrets`
- `company_secret_versions`
- `company_secret_bindings`
- `secret_access_events`
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
For local/default installs, the active provider is `local_encrypted`:
+34 -3
View File
@@ -125,19 +125,50 @@ When running `authenticated` mode, if the only instance admin is `local-board`,
This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode.
## 8. Current Code Reality (As Of 2026-02-23)
## 8. First Admin Setup For Fresh Authenticated Installs
Fresh authenticated installs start in `bootstrap_pending` until the first
`instance_admin` exists.
For `authenticated/private`, Paperclip supports a browser-first setup path:
1. open the Paperclip URL from the private network or appliance UI
2. sign in or create a Paperclip account
3. choose `Claim this instance` on the setup screen
That browser claim promotes the signed-in session user to the first instance
admin and then falls through to normal onboarding. The endpoint is available
only to real browser session actors in `authenticated/private`; unauthenticated
requests, agent keys, board API keys, and local implicit board actors are
rejected.
The CLI fallback remains supported in all authenticated setup states:
```sh
pnpm paperclipai auth bootstrap-ceo
```
That command prints a one-time first-admin invite URL. Browser claim and
bootstrap invite acceptance share the same first-admin transaction, so whichever
path wins first makes later attempts return a conflict.
For `authenticated/public`, browser first-admin claim is intentionally disabled.
Public deployments must use the high-entropy bootstrap invite path unless a
future public-hosted setup design explicitly changes this policy.
## 9. Current Code Reality (As Of 2026-02-23)
- runtime values are `local_trusted | authenticated`
- `authenticated` uses Better Auth sessions and bootstrap invite flow
- `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access
- company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent
## 9. Naming and Compatibility Policy
## 10. Naming and Compatibility Policy
- canonical naming is `local_trusted` and `authenticated` with `private/public` exposure
- no long-term compatibility alias layer for discarded naming variants
## 10. Relationship to Other Docs
## 11. Relationship to Other Docs
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
- V1 contract: `doc/SPEC-implementation.md`
+67 -2
View File
@@ -72,6 +72,13 @@ pnpm dev --bind lan
```
This runs dev as `authenticated/private` with a private-network bind preset.
On a fresh authenticated/private instance, open the app, sign in or create an
account, and use the setup screen to claim the first instance admin from the
browser. The CLI fallback remains:
```sh
pnpm paperclipai auth bootstrap-ceo
```
For Tailscale-only reachability on a detected tailnet address:
@@ -413,6 +420,62 @@ eval "$(pnpm paperclipai worktree env)"
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## App-Shipped Skills Catalog
The Paperclip app ships a curated catalog of company skills out of the box. The
catalog is a workspace package at `packages/skills-catalog`:
```text
packages/skills-catalog/
catalog/
bundled/<category>/<slug>/SKILL.md # recommended defaults
optional/<category>/<slug>/SKILL.md # role/domain-specific
generated/catalog.json # checked-in manifest
scripts/
build-catalog-manifest.ts # regenerate generated/catalog.json
validate-catalog.ts # validation only
src/ # builder + types consumed by server/CLI
```
Server and CLI import the generated manifest; they do not crawl repository
paths at request time. Root `skills/` remains reserved for Paperclip runtime
skills and is not part of the catalog.
Validate the catalog without writing the manifest:
```sh
pnpm --filter @paperclipai/skills-catalog validate
```
Regenerate `generated/catalog.json` after editing any catalog `SKILL.md`,
frontmatter, file inventory, category, or slug:
```sh
pnpm --filter @paperclipai/skills-catalog build:manifest
```
The package's `build` script runs `build:manifest` and then `tsc`; tests live
under `pnpm --filter @paperclipai/skills-catalog test`. Validation fails when:
- a catalog entry is not under `catalog/bundled/<category>/<slug>` or
`catalog/optional/<category>/<slug>`
- `SKILL.md` is missing or the frontmatter `name`/`description` is empty
- the frontmatter `key` disagrees with the generated canonical key
- two catalog entries share an `id`, `key`, or `slug`
- file inventory contains absolute paths, `..`, broken symlinks, or files
outside the skill directory
- the regenerated manifest differs from the checked-in
`generated/catalog.json`
Trust level is derived from inventory: `markdown_only` (markdown + references
only), `assets` (other non-script files), or `scripts_executables` (any
executable script). The build contract is documented in
`doc/plans/2026-05-26-skills-cli-catalog-contract.md`.
CI runs `pnpm --filter @paperclipai/skills-catalog validate` and the package's
vitest suite, so always regenerate the manifest in the same commit as the
catalog change.
## Quick Health Checks
In another terminal:
@@ -554,10 +617,12 @@ pnpm paperclipai dashboard get
See full command reference in `doc/CLI.md`.
## OpenClaw Invite Onboarding Endpoints
## Agent Invite Onboarding Endpoints
Agent-oriented invite onboarding now exposes machine-readable API docs:
The board UI generates agent onboarding prompts from the add-agent modal (`+` in the agent sidebar), so agent onboarding sits with the rest of agent creation rather than company member invite settings.
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates.
@@ -575,7 +640,7 @@ pnpm smoke:openclaw-join
What it validates:
- invite creation for agent-only join
- agent join request using `adapterType=openclaw`
- agent join request using `adapterType=openclaw_gateway`
- board approval + one-time API key claim semantics
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
+10
View File
@@ -117,6 +117,16 @@ services:
- bootstrap invite URL defaults
- hostname allowlist defaults (hostname extracted from URL)
For fresh `authenticated/private` Docker or appliance-style installs, the first
admin can now be claimed entirely from the browser after sign-in. Open the
Paperclip URL, sign in or create an account, then choose `Claim this instance`
on the setup screen. This browser claim is disabled for `authenticated/public`;
public deployments should run the high-entropy CLI invite fallback instead:
```sh
pnpm paperclipai auth bootstrap-ceo
```
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
+1
View File
@@ -118,6 +118,7 @@ Paperclips core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
+121 -11
View File
@@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Company model | Company is first-order; all business entities are company-scoped |
| Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
| Visibility | Full visibility to board and all agents in same company |
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
@@ -207,6 +207,8 @@ Invariant:
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent.
## 7.6 `issues` (core task entity)
- `id` uuid pk
@@ -309,7 +311,32 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
- `details` jsonb null
- `created_at` timestamptz not null default now()
## 7.12 `company_secrets` + `company_secret_versions`
## 7.12 `project_memberships` + `agent_memberships`
Per-user project/agent membership is personal visibility state for board users. It only controls whether a resource appears in the current user's sidebar; it must not grant or revoke access to all-pages, detail pages, selectors, assignment flows, search, or existing permissions.
`project_memberships`:
- `id` uuid pk
- `company_id` uuid fk `companies.id` not null
- `project_id` uuid fk `projects.id` not null
- `user_id` text not null
- `state` enum-like text: `joined | left`
- `created_at` timestamptz not null default now()
- `updated_at` timestamptz not null default now()
- unique `(company_id, user_id, project_id)`
`agent_memberships` mirrors the same shape with `agent_id` instead of `project_id` and unique `(company_id, user_id, agent_id)`.
Invariants:
- Missing membership rows mean `joined` for backward compatibility.
- Mutations are board-user-only `/me` operations; agent API keys are rejected.
- Viewer-role board users may update only their own membership rows through the narrow self-service helper.
- Target project/agent ownership is checked against the path company before mutation.
- Successful state changes write `resource_membership.joined` or `resource_membership.left` activity entries.
## 7.13 `company_secrets` + `company_secret_versions`
- Secret values are not stored inline in `agents.adapter_config.env`.
- Agent env entries should use secret refs for sensitive values.
@@ -323,7 +350,7 @@ Operational policy:
- Activity and approval payloads must not persist raw sensitive values.
- Config revisions may include redacted placeholders; such revisions are non-restorable for redacted fields.
## 7.13 Required Indexes
## 7.14 Required Indexes
- `agents(company_id, status)`
- `agents(company_id, reports_to)`
@@ -341,8 +368,12 @@ Operational policy:
- `issue_attachments(company_id, issue_id)`
- `company_secrets(company_id, name)` unique
- `company_secret_versions(secret_id, version)` unique
- `project_memberships(company_id, user_id)`
- `project_memberships(company_id, user_id, project_id)` unique
- `agent_memberships(company_id, user_id)`
- `agent_memberships(company_id, user_id, agent_id)` unique
## 7.14 `assets` + `issue_attachments`
## 7.15 `assets` + `issue_attachments`
- `assets` stores provider-backed object metadata (not inline bytes):
- `id` uuid pk
@@ -376,6 +407,10 @@ Operational policy:
- `created_by_user_id` uuid/text fk null
- `updated_by_agent_id` uuid fk null
- `updated_by_user_id` uuid/text fk null
- `locked_at` timestamptz null
- `locked_by_agent_id` uuid fk null
- `locked_by_user_id` uuid/text fk null
- Locked documents are immutable until unlocked. Board operators can lock/unlock; agent writes to a locked key create a new issue document with a derived key instead of overwriting the locked document.
- `document_revisions` stores append-only history:
- `id` uuid pk
- `company_id` uuid fk not null
@@ -396,7 +431,7 @@ The current implementation includes additional V1-control-plane tables beyond th
- Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes.
- Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables.
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`.
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`.
- Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos.
## 8. State Machines
@@ -481,6 +516,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
| Report cost | yes | yes |
| Set company budget | yes | no |
| Set subordinate budget | yes | yes (manager subtree only) |
| Set work-object visibility (issue/project) | no | no (pro gate) |
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
The approved term set is:
- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines).
- **Agent config visibility**: adapter/runtime config metadata and secret-access policy.
- **Assignment/invocation permission**: who may modify or execute a task.
- **Work-object visibility**: who can read/write issues, comments, projects, and attachments.
- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs.
- **Escalation authority**: where refusal/blocked decisions route (manager, then board).
## 9.5 Core V1 Rule: what “private” means
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
| Permission area | Free / V1 default | Pro / Enterprise |
|---|---|---|
| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) |
| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles |
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows |
## 9.7 Recommended first-slice implementation order
1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates.
2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects.
3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks).
4. Standardize read-path redaction for secrets and secret references, including logs and activity.
5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit.
## 9.8 Scoped Task Assignment Grants
`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks.
`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint:
- Project scope: `projectId`, `projectIds`, or `allow: ["project:<projectId>"]`.
- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:<agentId>"]`.
- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:<agentId>"]`.
When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources.
## 10. API Contract (REST)
@@ -524,6 +612,8 @@ All endpoints are under `/api` and return JSON.
- `GET /issues/:issueId/documents`
- `GET /issues/:issueId/documents/:key`
- `PUT /issues/:issueId/documents/:key`
- `POST /issues/:issueId/documents/:key/lock`
- `POST /issues/:issueId/documents/:key/unlock`
- `GET /issues/:issueId/documents/:key/revisions`
- `DELETE /issues/:issueId/documents/:key`
- `POST /issues/:issueId/checkout`
@@ -562,14 +652,28 @@ Server behavior:
- `GET /projects/:projectId`
- `PATCH /projects/:projectId`
## 10.6 Approvals
## 10.6 Current-user Resource Memberships
- `GET /companies/:companyId/resource-memberships/me`
- `PUT /companies/:companyId/resource-memberships/me/projects/:projectId`
- `PUT /companies/:companyId/resource-memberships/me/agents/:agentId`
Request payload:
```json
{ "state": "joined" }
```
Allowed states are `joined` and `left`. Endpoints require a concrete board user and active company membership, reject agent API keys, and only mutate the caller's own sidebar visibility state. Joining/leaving is idempotent; missing rows read as `joined`.
## 10.7 Approvals
- `GET /companies/:companyId/approvals?status=pending`
- `POST /companies/:companyId/approvals`
- `POST /approvals/:approvalId/approve`
- `POST /approvals/:approvalId/reject`
## 10.7 Cost and Budgets
## 10.8 Cost and Budgets
- `POST /companies/:companyId/cost-events`
- `GET /companies/:companyId/costs/summary`
@@ -578,7 +682,7 @@ Server behavior:
- `PATCH /companies/:companyId/budgets`
- `PATCH /agents/:agentId/budgets`
## 10.8 Activity and Dashboard
## 10.9 Activity and Dashboard
- `GET /companies/:companyId/activity`
- `GET /companies/:companyId/dashboard`
@@ -590,7 +694,7 @@ Dashboard payload must include:
- month-to-date spend and budget utilization
- pending approvals count
## 10.9 Error Semantics
## 10.10 Error Semantics
- `400` validation error
- `401` unauthenticated
@@ -600,7 +704,7 @@ Dashboard payload must include:
- `422` semantic rule violation
- `500` server error
## 10.10 Current Implementation API Addenda
## 10.11 Current Implementation API Addenda
The current app also exposes V1-supporting surfaces for:
@@ -671,7 +775,13 @@ Behavior:
- `thin`: send IDs and pointers only; agent fetches context via API
- `fat`: include current assignments, goal summary, budget snapshot, and recent comments
## 11.5 Scheduler Rules
## 11.5 Recovery Model Profiles
The optional `modelProfiles.cheap` lane is not a retry worker lane. Paperclip may request the cheap profile only for status-only recovery coordination, and those wakes must include guard context that prevents deliverable work and document/plan updates (`allowDeliverableWork: false`, `allowDocumentUpdates: false`, `resumeRequiresNormalModel: true`).
Failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, and downstream source-work child/requeue/resume contexts must use the normal/original model lane. If cheap recovery repairs liveness while actual work remains, the next live continuation path must be a separate normal-model worker run with cheap hints scrubbed.
## 11.6 Scheduler Rules
Per-agent schedule fields in `adapter_config`:
+2
View File
@@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
### Cross-Team Work
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

+122 -14
View File
@@ -1,7 +1,7 @@
# Execution Semantics
Status: Current implementation guide
Date: 2026-04-26
Date: 2026-05-23
Audience: Product and engineering
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
@@ -152,7 +152,73 @@ Blocked issues should stay idle while blockers remain unresolved. Paperclip shou
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
## 7. Non-Terminal Issue Liveness Contract
## 7. Accepted-Plan Decomposition
An accepted plan confirmation is permission to decompose one specific accepted plan revision into child issues.
This complements the existing accepted-plan continuation rule: once a plan is accepted, the source issue may create child implementation issues, but it must not start implementation work on the source issue itself during that continuation.
Paperclip must treat accepted-plan decomposition as an exact-once control-plane primitive, not as a free-floating wake that any later run may interpret again.
### Exact-once fingerprint
The canonical decomposition fingerprint is:
- `(sourceIssueId, acceptedPlanRevisionId)`
Where:
- `sourceIssueId` is the issue whose `plan` document revision was accepted
- `acceptedPlanRevisionId` is the accepted `plan` document revision
This is the product contract because the accepted revision is the thing being authorized for decomposition. Re-accepting, re-waking, or re-reading the same accepted revision must not authorize a second child tree. A later accepted revision on the same source issue is a new fingerprint and may produce a different decomposition result.
An implementation may also store the accepted interaction id, acceptance run id, or other evidence, but those values must collapse onto the same uniqueness guarantee. They must not allow a second decomposition claim for the same `(sourceIssueId, acceptedPlanRevisionId)` pair.
### Durable claim and durable result
Before creating child issues, the first decomposition attempt must create or reuse a durable record for the fingerprint.
That durable record must be able to answer, without reconstructing the thread from comments or transcripts:
- whether decomposition for the fingerprint is `in_flight` or `completed`
- which run or owner currently holds the in-flight claim
- which child issues, if any, have already been created under that fingerprint
- which final child issue ids belong to the completed result
Paperclip does not need to mandate a specific storage shape in this document. The record may live in a dedicated table, source-issue execution state, interaction metadata, or another durable product surface. What matters is the contract:
- the claim is durable before fan-out starts
- partial progress is durable while fan-out is underway
- the completed child result set is durable after fan-out finishes
If a run creates some children and then dies, retries must continue from the same fingerprint and reuse the already-recorded partial result. They must not restart decomposition as if nothing happened.
### Parent live path while decomposition is in flight
While decomposition for an accepted fingerprint is incomplete, the source issue must expose an explicit live path for that same fingerprint.
The accepted interaction by itself is only evidence that the plan was approved. It is not a sufficient live path once decomposition begins. The source issue must make it clear what moves the fingerprint forward next, such as:
- the active decomposition run
- a queued continuation wake for the same assignee
- a monitor or explicit recovery action tied to the same decomposition claim
- a blocked state that names the real blocker for finishing that claimed decomposition
If the live run disappears, Paperclip must repair, resume, or visibly block the existing claim. It must not leave the source issue in a state where a second run can interpret the same acceptance as fresh permission to create sibling issues again.
### Concurrent and repeat attempts
Every later run that encounters the same accepted-plan fingerprint must consult the durable claim/result before creating children.
- If no claim exists, the run may atomically create the claim and become the decomposition owner.
- If a claim exists and is `in_flight`, the later run must reuse that claim. It may resume the same decomposition if it is the valid continuation owner, or it may exit after observing that another run already owns the work.
- If a claim exists and is `completed`, the later run must reuse the recorded child result and must not create new sibling issues.
- If the prior attempt ended after partial child creation, the retry must continue under the same fingerprint and preserve the already-created child ids.
Concurrent accepted-plan runs are therefore idempotent relative to the fingerprint. Creating multiple child trees for the same `(sourceIssueId, acceptedPlanRevisionId)` pair is a product bug.
## 8. Non-Terminal Issue Liveness Contract
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
@@ -184,7 +250,7 @@ A valid recovery action must name:
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
@@ -196,6 +262,14 @@ Use an issue-backed recovery action only when the recovery is genuinely independ
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
#### Recovery action freshness
Source-scoped recovery actions are snapshots of the source issue's liveness state at the time the action was opened. They must be revalidated after newer durable source activity, including source issue status changes, assignee changes, blocker changes, execution policy or monitor changes, document or work-product updates that define a valid waiting path, and structured resume or disposition updates.
When newer source activity restores a valid live or waiting path, the recovery action is stale and should be folded through the explicit recovery lifecycle instead of being hidden or deleted. Folding means resolving or cancelling the recovery action with a resolution outcome and note that preserve the audit trail.
Plain comments alone do not make a recovery action stale. A comment can provide evidence, but the recovery action should remain visible when the source issue is still stalled and the comment does not create a valid action-path primitive such as a wake, monitor, interaction, approval, blocker, human owner, execution participant, terminal disposition, or delegated follow-up.
### Agent-assigned `todo`
This is dispatch state: ready to start, not yet actively claimed.
@@ -284,13 +358,13 @@ A blocker chain is covered only when its unresolved leaf is live or explicitly w
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
## 8. Crash and Restart Recovery
## 9. Crash and Restart Recovery
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
There are two distinct failure modes.
### 8.1 Stranded assigned `todo`
### 9.1 Stranded assigned `todo`
Example:
@@ -306,7 +380,7 @@ Recovery rule:
This is a dispatch recovery, not a continuation recovery.
### 8.2 Stranded assigned `in_progress`
### 9.2 Stranded assigned `in_progress`
Example:
@@ -322,20 +396,27 @@ Recovery rule:
This is an active-work continuity recovery.
## 9. Startup and Periodic Reconciliation
### 9.3 Recovery model-profile lane
Cheap model profiles are only for status-only operational recovery overhead. Paperclip may request `modelProfile: "cheap"` for bounded recovery-owner work that updates task liveness, clears bad status, records a disposition, or asks for human/manager intervention. Those wakes must carry guard context such as `allowDeliverableWork: false`, `allowDocumentUpdates: false`, and `resumeRequiresNormalModel: true`.
Automatic retries that can continue source work must use the original/normal model lane. This includes failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, assigned-todo dispatch recovery, and any run that can update repo files, issue documents, plans, work products, or attachments. When a cheap status-only recovery determines that actual work remains, it must hand back to a normal-model worker run before source work or persistent deliverable updates resume. Cheap recovery hints must be scrubbed from copied retry, resume, child, and downstream source-work contexts.
## 10. Startup and Periodic Reconciliation
Startup recovery and periodic recovery are different from normal wakeup delivery.
On startup and on the periodic recovery loop, Paperclip now does four things in sequence:
On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
1. reap orphaned `running` runs
2. resume persisted `queued` runs
3. reconcile stranded assigned work
4. scan silent active runs and create or update explicit watchdog recovery actions
4. scan silent active runs, revalidate their source issues, and either fold source-resolved watchdogs or create/update explicit watchdog recovery actions
5. reconcile productivity reviews
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
## 10. Silent Active-Run Watchdog
## 11. Silent Active-Run Watchdog
An active run can still be unhealthy even when its process is `running`. Paperclip treats prolonged output silence as a watchdog signal, not as proof that the run is failed.
@@ -360,7 +441,34 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
### Source-aware watchdog folding
Active-run watchdog work is source-aware. Before the watchdog creates, refreshes, escalates, or blocks on reviewer work, it must re-read the linked source issue and decide whether the watchdog signal is still about productive source work or only about stale run/process bookkeeping.
Fold watchdog work when all of these are true:
- the run is linked to a source issue in the same company
- the source issue is terminal (`done` or `cancelled`)
- durable source activity from the same run proves the source issue reached that terminal disposition after the stale-run or output-silence evidence point
- there is no independent evidence that the still-running or detached process is doing harmful work, still owns external cleanup that needs an operator decision, or needs a separate security/ownership review
Folding means resolving or cancelling the watchdog recovery action or issue-backed evaluation through the explicit recovery lifecycle. It must preserve the run id, source issue, detected silence or detached-process evidence, terminal source activity, decision reason, and best-effort process cleanup result. It must be idempotent for the `(companyId, runId, sourceIssueId)` signal and must not recursively recover the watchdog evaluation issue itself.
Do not fold watchdog work only because the run is quiet. The watchdog must still create or continue reviewer work when:
- the source issue is still `todo` or `in_progress`, because productive work may still be happening or stuck
- the source issue remains `in_progress` after a successful run with no valid disposition, because the successful-run handoff path owns that bounded correction
- the run terminated or disappeared while the source issue remains `in_progress` without a live path, because stranded assigned recovery owns that continuity repair
- the source issue is terminal but there is no durable same-run terminal activity after the stale evidence point
- there is independent evidence that the process may still be mutating external state, leaking resources, crossing company or ownership boundaries, or otherwise needs operator review
In the normal non-terminal case, critical silence can still create issue-backed evaluation work and block the source issue when blocking is necessary for correctness. In the source-resolved case, a completed source issue should not acquire a new manager review or blocker merely because an old run handle stayed active; only real unresolved work should block work.
This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
## 12. Auto-Recover vs Explicit Recovery vs Human Escalation
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
@@ -404,7 +512,7 @@ Examples:
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
## 12. What This Does Not Mean
## 13. What This Does Not Mean
These semantics do not change V1 into an auto-reassignment system.
@@ -421,7 +529,7 @@ The recovery model is intentionally conservative:
- open an explicit recovery action when the system can identify a bounded recovery owner/action
- escalate visibly when the system cannot safely keep going
## 13. Practical Interpretation
## 14. Practical Interpretation
For a board operator, the intended meaning is:
@@ -1,6 +1,6 @@
# 2026-03-14 Adapter Skill Sync Rollout
Status: Proposed
Status: Implemented for local adapters; gateway remains unsupported
Date: 2026-03-14
Audience: Product and engineering
Related:
@@ -25,8 +25,10 @@ Paperclip currently has these adapters:
- `claude_local`
- `codex_local`
- `cursor_local`
- `cursor`
- `gemini_local`
- `grok_local`
- `acpx_local`
- `opencode_local`
- `pi_local`
- `openclaw_gateway`
@@ -39,12 +41,14 @@ The current skill API supports:
Current implementation state:
- `codex_local`: implemented, `persistent`
- `codex_local`: implemented, `ephemeral`
- `claude_local`: implemented, `ephemeral`
- `cursor_local`: not yet implemented, but technically suited to `persistent`
- `gemini_local`: not yet implemented, but technically suited to `persistent`
- `pi_local`: not yet implemented, but technically suited to `persistent`
- `opencode_local`: not yet implemented; likely `persistent`, but with special handling because it currently injects into Claudes shared skills home
- `cursor`: implemented, `persistent`
- `gemini_local`: implemented, `persistent`
- `pi_local`: implemented, `persistent`
- `opencode_local`: implemented, `persistent`, with shared Claude skills home caveats
- `acpx_local`: implemented, `ephemeral` for Claude/Codex sub-agents and `unsupported` for custom commands
- `grok_local`: implemented, `ephemeral`
- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now
## 3. Product Principles
@@ -64,8 +68,7 @@ These adapters have a stable local skills directory that Paperclip can read and
Candidates:
- `codex_local`
- `cursor_local`
- `cursor`
- `gemini_local`
- `pi_local`
- `opencode_local` with caveats
@@ -84,7 +87,10 @@ These adapters do not have a meaningful Paperclip-owned persistent install state
Current adapter:
- `codex_local`
- `claude_local`
- `acpx_local` when configured for Claude or Codex
- `grok_local`
Expected UX:
@@ -99,6 +105,7 @@ These adapters cannot support skill sync without new external capabilities.
Current adapter:
- `acpx_local` when configured for custom commands
- `openclaw_gateway`
Expected UX:
@@ -114,7 +121,7 @@ Expected UX:
Target mode:
- `persistent`
- `ephemeral`
Current state:
@@ -122,15 +129,15 @@ Current state:
Requirements to finish:
- keep as reference implementation
- tighten tests around external custom skills and stale removal
- ensure imported company skills can be attached and synced without manual path work
- keep runtime-mounted snapshots separate from persistent install snapshots
- ensure imported company skills can be attached and mounted without manual path work
- keep `CODEX_HOME/skills` mutation scoped to heartbeat execution, not `skills/sync`
Success criteria:
- list installed managed and external skills
- sync desired skills into `CODEX_HOME/skills`
- preserve external user-managed skills
- desired skills are stored in Paperclip
- selected skills are linked into the effective `CODEX_HOME/skills` during runs
- no persistent installed/stale state is reported from `skills/sync`
### 5.2 Claude Local
@@ -162,18 +169,11 @@ Target mode:
Technical basis:
- runtime already injects Paperclip skills into `~/.cursor/skills`
- Paperclip reconciles desired skills into `~/.cursor/skills`
Implementation work:
Current state:
1. Add `listSkills` for Cursor.
2. Add `syncSkills` for Cursor.
3. Reuse the same managed-symlink pattern as Codex.
4. Distinguish:
- managed Paperclip skills
- external skills already present
- missing desired skills
- stale managed skills
- implemented
Testing:
@@ -194,14 +194,11 @@ Target mode:
Technical basis:
- runtime already injects Paperclip skills into `~/.gemini/skills`
- Paperclip reconciles desired skills into `~/.gemini/skills`
Implementation work:
Current state:
1. Add `listSkills` for Gemini.
2. Add `syncSkills` for Gemini.
3. Reuse managed-symlink conventions from Codex/Cursor.
4. Verify auth remains untouched while skills are reconciled.
- implemented
Potential caveat:
@@ -219,14 +216,11 @@ Target mode:
Technical basis:
- runtime already injects Paperclip skills into `~/.pi/agent/skills`
- Paperclip reconciles desired skills into `~/.pi/agent/skills`
Implementation work:
Current state:
1. Add `listSkills` for Pi.
2. Add `syncSkills` for Pi.
3. Reuse managed-symlink helpers.
4. Verify session-file behavior remains independent from skill sync.
- implemented
Success criteria:
@@ -250,9 +244,7 @@ This is product-risky because:
Plan:
Phase 1:
- implement `listSkills` and `syncSkills`
- implemented `listSkills` and `syncSkills`
- treat it as `persistent`
- explicitly label the home as shared in UI copy
- only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed
@@ -290,6 +282,30 @@ Future target:
- likely a fourth truth model eventually, such as remote-managed persistent state
- for now, keep the current API and treat gateway as unsupported
### 5.8 ACPX Local
Target mode:
- `ephemeral` for built-in Claude/Codex ACPX sub-agents
- `unsupported` for custom ACP commands
Success criteria:
- Claude/Codex ACPX snapshots show skills as configured for the next session
- custom command snapshots keep desired skills tracked only and do not imply runtime sync
### 5.9 Grok Local
Target mode:
- `ephemeral`
Success criteria:
- desired skills are stored in Paperclip
- selected skills are copied into the execution workspace for the next run
- no persistent installed/stale state is reported from `skills/sync`
## 6. API Plan
## 6.1 Keep the current minimal adapter API
@@ -333,14 +349,13 @@ Additional UI requirement for shared-home adapters:
Ship:
- `cursor_local`
- `cursor`
- `gemini_local`
- `pi_local`
Rationale:
Status:
- these are the closest to Codex in architecture
- they already inject into stable local skill homes
- implemented
### Phase 2: OpenCode shared-home support
@@ -348,10 +363,9 @@ Ship:
- `opencode_local`
Rationale:
Status:
- technically feasible now
- needs slightly more careful product language because of the shared Claude skills home
- implemented with shared Claude skills-home warning
### Phase 3: Gateway support decision
@@ -390,10 +404,10 @@ Adapter-wide skill support is ready when all are true:
The recommended immediate order is:
1. `cursor_local`
1. `cursor`
2. `gemini_local`
3. `pi_local`
4. `opencode_local`
5. defer `openclaw_gateway`
That gets Paperclip from “skills work for Codex and Claude” to “skills work for the whole local-adapter family,” which is the meaningful V1 milestone.
The local-adapter family now has explicit truth models. The remaining V1 boundary is `openclaw_gateway`, which should stay unsupported until the gateway protocol can report real remote skill state.
@@ -0,0 +1,90 @@
# Scaled Kanban Board Design
Date: 2026-05-05
Branch: `feat/scaled-kanban-board`
## Context
The Issues page currently supports list and board modes. List mode already has grouping, sorting, filtering, nested parent/child rows, deferred row rendering, and incremental render limits. Board mode uses classic status columns with draggable cards. It fetches per-status board data, but the current UI still presents each lane as an unbounded stack of cards, which becomes tall and heavy when a company has hundreds of issues.
The goal is to keep the Kanban mental model while making high-volume boards usable. This is a UI-first change. It should not introduce schema changes or new API contracts in the first pass.
## Problem
When Paperclip has many issues, board columns get too tall and slow. The operator loses the ability to scan the board quickly, and rendering or dragging through long columns becomes unpleasant. The first version should solve this by reducing the number of visible cards per column and by collapsing low-signal columns, not by replacing Kanban with a different inventory surface.
## Design
Board mode remains status-column based. Each column shows its total issue count, a bounded set of visible cards, and a local affordance to reveal more cards in that column. The board should keep active workflow lanes expanded by default and collapse cold or noisy lanes once issue volume is high.
Default high-volume behavior activates when the filtered board has more than 100 issues:
- Compact cards are used by default.
- `backlog`, `done`, and `cancelled` auto-collapse to narrow rails.
- `todo`, `in_progress`, `in_review`, and `blocked` remain expanded by default.
- Each expanded column renders an initial 10 cards by default.
- The user can choose a page size of 10, 25, or 50 cards per column.
- The user can reveal one additional page at a time in each column without changing other columns.
- Drag and drop continues to work for visible cards.
The toolbar should expose compact controls for:
- toggling compact cards
- hiding or showing cold lanes
- choosing cards per column
- resetting board density to defaults
These preferences should persist through the existing issue view-state/localStorage mechanism and remain scoped by company.
## Component Shape
`IssuesList` remains the owner of issue board view state. It should store board-density preferences alongside the existing issue view state, including compact card preference, cold-lane mode, and cards-per-column page size.
`KanbanBoard` receives board tuning props from `IssuesList` and delegates per-lane display to `KanbanColumn`.
`KanbanColumn` owns only local presentation mechanics for a lane:
- whether the lane is rendered as an expanded column or collapsed rail
- how many cards are currently visible in that lane
- the local "show more" action
`KanbanCard` gets a compact variant. The compact card should still show the issue identifier, title, live state, priority, and assignee when available, but with tighter spacing and fewer vertical affordances.
## Data Flow
The first implementation uses the current issue data already available to board mode. No database, shared type, or route change is required.
Column totals are computed from the in-memory filtered board issues. If a column reaches the existing remote board query cap, the existing warning remains the truth source that more filtering may be required.
Future server-side column pagination can be added later if the UI-only version is not enough for very large instances.
## Error Handling
This feature should not introduce new network errors. Existing issue loading and update errors continue to surface through the Issues page.
For drag and drop:
- Moving a visible card keeps the current optimistic behavior.
- Hidden cards remain hidden until revealed.
- A collapsed lane rail is a valid drop target. Dropping onto it moves the issue to that status and keeps the lane collapsed.
## Testing
Focused tests should cover:
- board mode passes density preferences into `KanbanBoard`
- columns render only the initial visible card count
- "show more" reveals more cards in a single column
- high-volume cold lanes render as collapsed rails by default
- compact cards preserve identifier/title/live/priority/assignee signals
- drag/drop status updates still call `onUpdateIssue`
Manual verification should include opening the Issues board with a large fixture or mocked issue set and confirming that columns remain usable with hundreds of issues.
## Out of Scope
- Server-side per-column pagination
- New issue schema fields
- Replacing Kanban with a dense table or action-only board
- Changing issue status semantics
- Broad visual redesign of the Issues page
+250
View File
@@ -0,0 +1,250 @@
# Scaled Kanban Board Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the Issues Kanban board usable with hundreds of issues by adding compact high-volume rendering, collapsed cold lanes, and per-column reveal controls.
**Architecture:** Keep the change UI-only. `IssuesList` owns persisted board density preferences in existing company-scoped view state, while `KanbanBoard` owns lane rendering, card density, collapsed rails, and per-column "show more" state.
**Tech Stack:** React 19, TypeScript, Vite, Vitest/jsdom, `@dnd-kit/core`, `@dnd-kit/sortable`, Tailwind utility classes.
---
## File Structure
- Modify `ui/src/components/IssuesList.tsx`: extend `IssueViewState`, derive high-volume board preferences, add toolbar controls, pass props into `KanbanBoard`.
- Modify `ui/src/components/KanbanBoard.tsx`: add compact cards, collapsed rail lanes, visible-card limits, and per-column reveal behavior.
- Create `ui/src/components/KanbanBoard.test.tsx`: focused tests for high-volume behavior and drag/drop update callback.
- Modify `ui/src/components/IssuesList.test.tsx`: update the mocked `KanbanBoard` expectations for new props.
- Keep `doc/plans/2026-05-05-scaled-kanban-board-design.md` as the design source of truth.
## Task 1: Add Kanban Board Scaling Mechanics
**Files:**
- Modify: `ui/src/components/KanbanBoard.tsx`
- Create: `ui/src/components/KanbanBoard.test.tsx`
- [ ] **Step 1: Write focused tests**
Create `ui/src/components/KanbanBoard.test.tsx` with tests that render 60 todo issues and assert:
```tsx
renderBoard({ issues: createIssues(60, "todo"), compactCards: true, initialVisibleCount: 10, revealIncrement: 10 });
expect(container.textContent).toContain("Showing 10 of 60");
expect(container.textContent).toContain("Show 10 more");
```
Also test collapsed rails:
```tsx
renderBoard({ issues: createIssues(3, "done"), collapsedStatuses: ["done"] });
expect(container.textContent).toContain("Done");
expect(container.textContent).toContain("3");
expect(container.textContent).not.toContain("Issue 1");
```
- [ ] **Step 2: Run tests to verify failure**
Run:
```bash
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
```
Expected: fail because `KanbanBoard.test.tsx` is new and the props/behavior do not exist.
- [ ] **Step 3: Implement minimal board behavior**
In `KanbanBoard.tsx`, add exported constants:
```ts
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE = 10;
export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const;
```
Extend props:
```ts
compactCards?: boolean;
collapsedStatuses?: string[];
initialVisibleCount?: number;
revealIncrement?: number;
```
Add per-status visible-count state keyed by status. Expanded columns render `issues.slice(0, visibleCount)` and show a button when hidden issues remain. Collapsed columns render a narrow droppable rail with status icon, label, and count, but no cards.
Reset per-status visible-count state when `initialVisibleCount` or `revealIncrement` changes so choosing a smaller cards-per-column preset does not leave a column expanded past the newly selected page size.
- [ ] **Step 4: Preserve drag/drop**
Keep `DndContext`, `SortableContext`, and `handleDragEnd` status detection. Because collapsed rails use `useDroppable({ id: status })`, dropping a visible card onto a rail continues to resolve `targetStatus` through the existing status-id branch.
- [ ] **Step 5: Run focused test**
Run:
```bash
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
```
Expected: pass.
- [ ] **Step 6: Commit**
```bash
git add ui/src/components/KanbanBoard.tsx ui/src/components/KanbanBoard.test.tsx
git commit -m "Scale kanban board columns"
```
## Task 2: Wire Board Density State Into IssuesList
**Files:**
- Modify: `ui/src/components/IssuesList.tsx`
- Modify: `ui/src/components/IssuesList.test.tsx`
- [ ] **Step 1: Write/update tests**
In `IssuesList.test.tsx`, update the `KanbanBoard` mock to capture:
```ts
compactCards?: boolean;
collapsedStatuses?: string[];
initialVisibleCount?: number;
revealIncrement?: number;
```
Add a test that stores board mode in localStorage, renders more than 100 issues, and expects:
```ts
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
compactCards: true,
collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]),
initialVisibleCount: 10,
revealIncrement: 10,
}));
```
- [ ] **Step 2: Run test to verify failure**
Run:
```bash
pnpm exec vitest run ui/src/components/IssuesList.test.tsx
```
Expected: fail because `IssuesList` does not pass the new props yet.
- [ ] **Step 3: Add persisted board density preferences**
Extend `IssueViewState`:
```ts
boardCardDensity: "auto" | "compact" | "comfortable";
boardColdLaneMode: "auto" | "collapsed" | "expanded";
boardColumnPageSize: 10 | 25 | 50;
```
Default the density modes to `"auto"` and page size to `10`. Derive:
```ts
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
const boardCompactCards = viewState.boardCardDensity === "compact"
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
const boardCollapsedStatuses = viewState.boardColdLaneMode === "collapsed"
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
? [...KANBAN_COLD_STATUSES]
: [];
```
- [ ] **Step 4: Add toolbar controls**
When `viewState.viewMode === "board"`, add small outline/icon buttons near the existing view controls:
```tsx
<Button ... title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}>...</Button>
<Button ... title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}>...</Button>
<Button ... title="Cards per column">...</Button>
<Button ... title="Reset board density">...</Button>
```
Use lucide icons already available or import `ChevronsDownUp`, `PanelTopClose`, and `RotateCcw`.
- [ ] **Step 5: Pass board props**
Update the `KanbanBoard` call:
```tsx
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
compactCards={boardCompactCards}
collapsedStatuses={boardCollapsedStatuses}
initialVisibleCount={viewState.boardColumnPageSize}
revealIncrement={viewState.boardColumnPageSize}
onUpdateIssue={onUpdateIssue}
/>
```
- [ ] **Step 6: Run focused tests**
Run:
```bash
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
```
Expected: pass.
- [ ] **Step 7: Commit**
```bash
git add ui/src/components/IssuesList.tsx ui/src/components/IssuesList.test.tsx
git commit -m "Wire issue board density controls"
```
## Task 3: Verification And PR Prep
**Files:**
- Verify existing changes only.
- [ ] **Step 1: Run targeted UI tests**
```bash
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
```
Expected: pass.
- [ ] **Step 2: Run broader cheap test path**
```bash
pnpm test
```
Expected: pass.
- [ ] **Step 3: Check worktree**
```bash
git status --short
```
Expected: only intentional changes before committing, or clean after final commit.
- [ ] **Step 4: Prepare PR**
Read `.github/PULL_REQUEST_TEMPLATE.md` and use it for the PR body. Include:
- design spec path
- scaled Kanban behavior summary
- test commands and results
- Model Used section with the current Codex model details available in this session
## Self-Review
- Spec coverage: The plan covers compact high-volume board cards, collapsed cold lanes, cards-per-column presets, per-column reveal controls, persisted board preferences, current API reuse, and focused tests.
- Placeholder scan: No unresolved markers or unspecified implementation placeholders remain.
- Type consistency: The plan consistently uses `boardCardDensity`, `boardColdLaneMode`, `boardColumnPageSize`, `compactCards`, `collapsedStatuses`, `initialVisibleCount`, and `revealIncrement`.
@@ -0,0 +1,486 @@
# Skills CLI And Catalog Contract
Status: Phase A engineering contract
Date: 2026-05-26
Source plan: approved Paperclip skills CLI and catalog plan
This document freezes the first implementation contract for the `paperclipai skills`
command group and the app-shipped skills catalog. It is intentionally a build
contract, not a full product spec.
## Decisions
- `paperclipai skills` manages Paperclip company skills. It does not manage
local adapter homes directly.
- Installing a skill means adding or updating a company-scoped
`company_skills` record.
- Attaching a skill to an agent is a separate agent desired-state operation.
- Adapter runtime sync is a third step handled through adapter skill APIs.
- Root `skills/` remains reserved for Paperclip runtime and operational skills.
- App-shipped catalog skills live in `packages/skills-catalog`, not root
`skills/`.
- Catalog skills are inspectable before install. Inspection never mutates company
state.
- External sources continue to use the existing company skill import API in the
first release. No separate marketplace, tap, or source registry is part of this
phase.
- Agent desired skills continue to live in
`adapterConfig.paperclipSkillSync.desiredSkills` for the first release. Do not
add a normalized `agent_skills` table unless later implementation evidence
requires it.
## Terms
- Company skill: a row in `company_skills`, owned by one company.
- Catalog skill: an app-shipped skill entry in `@paperclipai/skills-catalog`.
- Skill ref: a user-supplied company skill reference. The CLI accepts company
skill `id`, canonical `key`, or unique `slug`.
- Catalog ref: a user-supplied catalog reference. The CLI accepts catalog `id`,
canonical `key`, or unique `slug`.
- Desired skills: the skill key set stored on the agent adapter config.
- Runtime snapshot: the adapter-reported `AgentSkillSnapshot` for desired,
installed, missing, stale, external, required, or unsupported skills.
## CLI Contract
All skills commands use the existing client command stack:
- Global client options: `--data-dir`, `--config`, `--context`, `--profile`,
`--api-base`, `--api-key`, and `--json`.
- Company-scoped commands also accept `-C, --company-id <id>` and otherwise use
`PAPERCLIP_COMPANY_ID` or the active context profile.
- Human output goes to stdout. Errors go to stderr.
- `--json` prints pretty JSON and no decorative labels.
- Successful commands exit `0`. Validation, API, or conflict errors exit `1`.
- API errors use the existing `API error <status>: <message>` formatting.
- Mutating commands print a short summary in human mode and the raw result in
JSON mode.
- Commands that can delete or clear state must prompt in a TTY. In non-TTY mode
they must require `--yes`.
### Company Skill Commands
These commands are Phase B and must work over existing APIs.
| Command | Behavior | JSON output |
|---|---|---|
| `skills list` | Lists company skills from `GET /api/companies/:companyId/skills`. Human rows include `id`, `key`, `slug`, `name`, `source`, `trust`, `compatibility`, and `attachedAgents`. | `CompanySkillListItem[]` |
| `skills show <skill-ref>` | Resolves `id`, `key`, or unique `slug`, then reads detail. Ambiguous slugs are conflicts. | `CompanySkillDetail` |
| `skills file <skill-ref> [--path <path>]` | Resolves the skill, reads a file with default `SKILL.md`, and prints raw file content in human mode. This command must remain pipeable. | `CompanySkillFileDetail` |
| `skills import <source>` | Calls existing import API. Source may be a local path, GitHub URL, skills.sh URL or command, `owner/repo`, `owner/repo/skill`, or URL-like source already accepted by the server. | `CompanySkillImportResult` |
| `skills create --name <name> [--slug <slug>] [--description <text>] [--body-file <path|->]` | Creates a managed local company skill. If `--body-file` is omitted, the server default body is used. `-` reads markdown from stdin. | `CompanySkill` |
| `skills scan-projects [--project-id <id>...] [--workspace-id <id>...]` | Calls project scan. Repeated flags become arrays. With neither flag, scan all accessible project workspaces. | `CompanySkillProjectScanResult` |
| `skills check [skill-ref]` | Reads update status for one skill, or for every listed company skill when no ref is provided. Unsupported statuses are shown, not hidden. | `CompanySkillCheckRow[]` |
| `skills update <skill-ref>` | Installs the update for one skill through the existing install-update API. | `CompanySkillUpdateRow` |
| `skills update --all` | Checks all skills, installs only those with `hasUpdate=true`, and reports skipped unsupported or current skills. | `CompanySkillUpdateRow[]` |
| `skills remove <skill-ref> [--yes]` | Deletes one company skill after confirmation. | `CompanySkill` |
`CompanySkillCheckRow` is a CLI-side shape:
```ts
interface CompanySkillCheckRow {
skill: Pick<CompanySkillListItem, "id" | "key" | "slug" | "name">;
status: CompanySkillUpdateStatus;
}
```
`CompanySkillUpdateRow` is a CLI-side shape:
```ts
interface CompanySkillUpdateRow {
skillRef: string;
action: "updated" | "skipped" | "failed";
skill?: CompanySkill;
status?: CompanySkillUpdateStatus;
reason?: string;
}
```
### Agent Skill Commands
These commands are Phase B and use existing agent skill APIs.
| Command | Behavior | JSON output |
|---|---|---|
| `skills agent list <agent-ref>` | Resolves the agent using existing agent reference behavior, then prints the adapter `AgentSkillSnapshot`. Human rows include `key`, `runtimeName`, `desired`, `managed`, `required`, `state`, `origin`, and `detail`. | `AgentSkillSnapshot` |
| `skills agent sync <agent-ref> --skill <skill-ref>...` | Replaces the agent's non-required desired skill set with the supplied refs and triggers adapter sync. Required Paperclip skills remain enforced by the server. | `AgentSkillSnapshot` |
| `skills agent clear <agent-ref> [--yes]` | Clears non-required desired skills by sending an empty desired list, then returns the adapter snapshot. | `AgentSkillSnapshot` |
The word `sync` is deliberate: it is a desired-state replacement, not an append.
An additive command can be added later if operators need it.
### Catalog CLI Commands
These commands are Phase E and depend on the catalog APIs from Phase D.
| Command | Behavior | JSON output |
|---|---|---|
| `skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]` | Lists app-shipped catalog skills. Human rows include `id`, `key`, `kind`, `category`, `slug`, `name`, `trust`, and `recommendedForRoles`. | `CatalogSkillListItem[]` |
| `skills search <query> [--kind bundled|optional] [--category <slug>]` | Alias for catalog browse with `query`. | `CatalogSkillListItem[]` |
| `skills inspect <catalog-ref>` | Shows app-shipped catalog detail and file inventory. Does not mutate company state. | `CatalogSkillDetail` |
| `skills install <catalog-ref> [--as <slug>] [--force]` | Installs a catalog skill into a company library. `--as` overrides the company skill slug. `--force` may replace a same-key catalog skill but must not bypass hard validation or dangerous security findings. | `CompanySkillInstallCatalogResult` |
Catalog commands are for the app-shipped Paperclip catalog only. External GitHub,
skills.sh, local path, and URL installs remain under `skills import <source>` in
the first release.
## Catalog Package Contract
Add a workspace package:
```text
packages/skills-catalog/
package.json
tsconfig.json
src/
index.ts
types.ts
catalog/
bundled/
<category>/
<slug>/
SKILL.md
references/
scripts/
assets/
optional/
<category>/
<slug>/
SKILL.md
references/
scripts/
assets/
generated/
catalog.json
scripts/
build-catalog-manifest.ts
validate-catalog.ts
```
Package name: `@paperclipai/skills-catalog`.
The package exports:
- `catalogManifest`
- `catalogSkills`
- `resolveCatalogSkillRef(ref)`
- `getCatalogSkill(id)`
- TypeScript types for every manifest shape
Server and CLI code must import the generated manifest. They must not crawl
arbitrary repository paths at request time.
## Catalog Manifest
The generated artifact is `packages/skills-catalog/generated/catalog.json`.
It is checked in and regenerated by the package build or validation script.
```ts
interface CatalogManifest {
schemaVersion: 1;
packageName: "@paperclipai/skills-catalog";
packageVersion: string;
generatedAt: string;
skills: CatalogSkill[];
}
interface CatalogSkill {
id: string;
key: string;
kind: "bundled" | "optional";
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: "markdown_only" | "assets" | "scripts_executables";
compatibility: "compatible" | "unknown" | "invalid";
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
}
interface CatalogSkillFile {
path: string;
kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other";
sizeBytes: number;
sha256: string;
}
```
`id` is path-safe:
```text
paperclipai:<kind>:<category>:<slug>
```
`key` is the canonical company skill key installed into `company_skills`:
```text
paperclipai/<kind>/<category>/<slug>
```
Example:
```json
{
"id": "paperclipai:bundled:software-development:github-pr-workflow",
"key": "paperclipai/bundled/software-development/github-pr-workflow",
"kind": "bundled",
"category": "software-development",
"slug": "github-pr-workflow",
"name": "github-pr-workflow",
"description": "Prepare pull requests, review responses, and verification notes.",
"path": "catalog/bundled/software-development/github-pr-workflow",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": ["engineer"],
"requires": [],
"tags": ["github", "pull-requests"],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 1200,
"sha256": "..."
}
],
"contentHash": "sha256:..."
}
```
## Catalog Skill Frontmatter
Each catalog `SKILL.md` must include:
```yaml
---
name: github-pr-workflow
description: Prepare pull requests, review responses, and verification notes.
key: paperclipai/bundled/software-development/github-pr-workflow
recommendedForRoles:
- engineer
tags:
- github
- pull-requests
---
```
Optional frontmatter:
- `slug`
- `defaultInstall`
- `requires`
- `metadata`
The manifest generator owns `kind`, `category`, `path`, `files`,
`trustLevel`, `compatibility`, and `contentHash`.
## Catalog Validation Rules
Validation must fail when:
- A catalog entry is not under `catalog/bundled/<category>/<slug>` or
`catalog/optional/<category>/<slug>`.
- `SKILL.md` is missing.
- `category` or `slug` is not a lowercase URL slug.
- `name` or `description` frontmatter is missing or empty.
- The frontmatter `key`, when present, does not equal the generated key.
- Two catalog entries have the same `id`, `key`, or `slug`.
- File inventory includes absolute paths, `..` segments, broken symlinks, or
files outside the skill directory.
- A file exceeds the package-level size limit chosen by implementation.
- A skill marked `compatible` cannot be parsed as Agent Skills markdown.
- The generated manifest differs from the checked-in
`generated/catalog.json`.
Trust level is derived from inventory:
- `scripts_executables` when any file is classified as `script`.
- `assets` when any file is classified as `asset` or `other` and no script is
present.
- `markdown_only` when all files are markdown, references, or `SKILL.md`.
Validation must report all discovered catalog errors when practical, not just
the first one.
## Catalog API Contract
Phase D adds read APIs and one company install API.
```text
GET /api/skills/catalog
GET /api/skills/catalog/:catalogId
GET /api/skills/catalog/:catalogId/files?path=SKILL.md
POST /api/companies/:companyId/skills/install-catalog
```
`GET /api/skills/catalog` accepts:
- `kind=bundled|optional`
- `category=<slug>`
- `q=<text>`
`catalogId` is the path-safe manifest `id`. The server should also support
resolution by `key` or unique `slug` where the ref is carried in a query or body,
but route parameters use `id` to avoid slash handling ambiguity.
Install request:
```ts
interface CompanySkillInstallCatalogRequest {
catalogSkillId: string;
slug?: string | null;
force?: boolean;
}
```
Install result:
```ts
interface CompanySkillInstallCatalogResult {
action: "created" | "updated" | "unchanged";
skill: CompanySkill;
catalogSkill: CatalogSkill;
warnings: string[];
}
```
Install behavior:
- Creates or updates a company skill with `sourceType="catalog"`.
- Uses catalog `key` as the company skill canonical key.
- Uses catalog `slug` unless `slug` is provided.
- Materializes the catalog files into a company-managed skill directory so
existing skill file reads continue to work.
- Stores provenance in metadata:
- `catalogId`
- `catalogKey`
- `catalogKind`
- `catalogCategory`
- `catalogPath`
- `packageName`
- `packageVersion`
- `originHash`
- `originVersion`
- `userModifiedAt`
- `updateHoldReason`
- Writes activity log entries for install and update.
- Returns `409` for duplicate slug/key conflicts that cannot be resolved safely.
- Returns `422` for invalid, incompatible, or hard-blocked catalog entries.
- `force` may replace a same-key catalog-managed skill. It must not bypass
company boundaries, permission checks, hard validation, or hard security
findings.
## Error Semantics
Use existing HTTP semantics:
- `400`: invalid CLI arguments, invalid query/body shape, or malformed refs.
- `401`: missing or invalid auth.
- `403`: authenticated principal lacks access or mutation permission.
- `404`: skill, catalog entry, agent, file, company, or source not found.
- `409`: ambiguous slug, duplicate key/slug, update conflict, or unsafe overwrite.
- `422`: semantic violation such as invalid skill content or unsupported source.
- `500`: unexpected server failure.
CLI messages should name the next useful correction, for example:
- `Skill slug "review" is ambiguous. Use an id or key.`
- `Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set a context profile.`
- `Catalog skill contains executable scripts and cannot be force-installed until security review semantics allow it.`
## Phase Acceptance Criteria
Phase A is complete when this contract is available in the repo and the issue
thread links it.
Phase B, CLI MVP:
- `paperclipai skills --help` exposes the Phase B command group.
- All Phase B commands work against existing company skills and agent skills
APIs without schema or server changes.
- Skill refs resolve by id, key, or unique slug.
- Human and JSON output are covered by focused CLI tests.
- `doc/CLI.md` documents company install vs agent desired sync vs runtime sync.
Phase C, catalog package:
- `packages/skills-catalog` is a workspace package.
- Build or validation regenerates `generated/catalog.json`.
- Validation covers frontmatter, id/key/slug uniqueness, directory shape, file
inventory, trust derivation, and stale generated output.
- Server and CLI can import the manifest without crawling arbitrary paths.
- Root `skills/` is not expanded with the app-shipped catalog.
Phase D, catalog APIs:
- Catalog list/detail/file APIs are read-only and covered by tests.
- Install-from-catalog creates auditable company-scoped skill records with
provenance metadata and materialized files.
- Company boundary and mutation permission checks match or exceed existing
company skill mutations.
- Duplicate and unsafe overwrite behavior is explicit and tested.
Phase E, catalog CLI:
- Operators can browse, search, inspect, and install app-shipped catalog skills.
- External source behavior remains routed through `skills import`.
- Output and errors follow the Phase B CLI conventions.
- Catalog install is clearly distinct from agent attach/sync in help and docs.
Phase F, update/reset/audit:
- Security review records decisions for origin hash, user modification detection,
reset, audit findings, and force behavior.
- Implementation follows the review or records explicit deferrals.
- Mutating reset/update actions are activity logged.
- Tests cover dangerous findings, force behavior, and unchanged/current states.
Phase G, adapter truth model:
- Adapter snapshots accurately report `unsupported`, `persistent`, or
`ephemeral`.
- Desired, missing, installed, stale, external, and required states are tested.
- External adapter plugins remain dynamically loaded. No hardcoded plugin imports
are added.
Phase H, UI:
- The existing Company Skills page is extended rather than replaced.
- UX guidance covers Company, Bundled, Optional, and External source views.
- Install preview shows source, trust, provenance, update state, and file
inventory.
- Agent attach/detach states are clear.
- Frontend handoff includes screenshots or equivalent browser evidence.
Phase I, initial skill content:
- Bundled and optional entries use the finalized frontmatter and category rules.
- Skill descriptions are specific enough for browse/search.
- No script-bearing skill lands without explicit security review evidence.
- Validation fixtures or tests cover representative content.
Phase J, QA and docs:
- QA validates CLI, catalog APIs, UI install, agent sync, portability, and adapter
snapshots against a dev instance.
- Blocking defects are linked as first-class issues.
- `doc/CLI.md`, `doc/DEVELOPING.md`, and skill workflow docs match shipped
behavior.
## Deferrals
- No cloud marketplace.
- No user-home tap registry.
- No hidden curator or autonomous catalog mutator.
- No normalized `agent_skills` table in the first release.
- No skill sets or bundles in the first release.
- No automatic install of every optional catalog skill.
- No replacement of company import/export as the portability path.
+8 -2
View File
@@ -2,7 +2,12 @@
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines/skills, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
If your plugin has background-like recurring work, model it as managed resources:
declare managed routines plus managed agents/projects/skills, then reconcile those
resources in worker actions. This gives operators visible work items, budgets,
pause controls, and consistent audits instead of hidden daemon behavior.
## Prerequisites
@@ -126,7 +131,8 @@ When you are done iterating locally, publish the package and reinstall the npm-p
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines/skills, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
- **Routine-first automation:** If your plugin should produce periodic issue work, prefer managed routines and `ctx.routines.managed` reconciliation over custom process loops or unobserved cron code.
## Troubleshooting
+35 -5
View File
@@ -13,6 +13,8 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
- Plugin database migrations are restricted to a host-derived plugin namespace.
- Plugin-managed surfaces are first-class records (agents, projects, routines, and
skills) rather than private plugin-only state.
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
only under `/api/plugins/:pluginId/api/*`.
- The host provides a small shared React component kit through
@@ -74,6 +76,7 @@ Worker:
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
- agents, plugin-managed agents, and agent sessions
- plugin-managed routines
- plugin-managed skills
- goals
- data/actions
- streams
@@ -134,11 +137,16 @@ paths; they always remain under `/api/plugins/:pluginId/api/*`.
Plugins that provide durable Paperclip business objects should declare them in
the manifest and let the host create or relink the actual records per company.
Do this for plugin-owned agents, plugin-owned projects, and recurring automation.
Do this for plugin-owned agents, projects, routines, and skills.
Do not hide long-lived work behind private plugin state when it should be visible
to the board, scoped to a company, audited, budgeted, and assigned like normal
Paperclip work.
Content-oriented plugins, such as LLM Wiki-style ingestion or durable knowledge
systems, should use the same pattern: managed projects for operation issues,
managed agents plus managed skills for LLM work, and managed routines for
ingest, lint, refresh, or maintenance runs.
Use these surfaces:
- Managed agents: declare top-level `agents[]` and require
@@ -155,10 +163,14 @@ Use these surfaces:
jobs that should create visible Paperclip issues. Prefer managed routines over
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
runtime maintenance that does not need a board-visible task trail.
- Managed skills: declare top-level `skills[]` and require `skills.managed`.
Use this for reusable plugin capabilities that should be surfaced to operators and
synced into Paperclip managed agents.
Managed resources are resolved by stable plugin keys, not hardcoded database
ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`,
`ctx.projects.managed.reconcile()`, and `ctx.routines.managed.reconcile()` for
`ctx.projects.managed.reconcile()`, `ctx.routines.managed.reconcile()`, and
`ctx.skills.managed.reconcile()` for
the current `companyId`. `reconcile()` creates the missing resource, relinks a
recoverable binding, or returns the existing resource. `reset()` reapplies the
manifest defaults when the operator wants to restore the plugin's suggested
@@ -185,6 +197,7 @@ const manifest: PaperclipPluginManifestV1 = {
"agents.managed",
"projects.managed",
"routines.managed",
"skills.managed",
"instance.settings.register",
],
entrypoints: {
@@ -231,6 +244,13 @@ const manifest: PaperclipPluginManifestV1 = {
],
},
],
skills: [
{
skillKey: "weekly-brief-skills",
displayName: "Weekly Briefer",
description: "Reusable skill for the managed research workflow.",
},
],
ui: {
slots: [
{
@@ -261,8 +281,9 @@ export default definePlugin({
const project = await ctx.projects.managed.reconcile("research", companyId);
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
const skill = await ctx.skills.managed.reconcile("weekly-brief-skills", companyId);
return { project, agent, routine };
return { project, agent, routine, skill };
});
},
});
@@ -270,14 +291,18 @@ export default definePlugin({
Authoring rules:
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
`routineKey` creates a new managed resource from the host's point of view.
- Keep keys stable once published. Renaming `agentKey`, `projectKey`,
`routineKey`, or `skillKey` creates a new managed resource from the host's
point of view.
- Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or
`ctx.agents.sessions` only after you have a real agent id, either selected by
the operator or resolved from `ctx.agents.managed`.
- Use managed routines for recurring or externally triggered work that should
produce tasks. Schedule, webhook, and API triggers are visible routine
triggers, and each run has the normal Paperclip issue/audit trail.
- Use managed skills for reusable operator-visible capabilities that are shared
by managed agents. Reconcile skill declarations by `skillKey` and keep the
declared skill markdown and files in sync with agent behavior.
- Use managed projects to keep plugin-generated work organized and to give
project-scoped plugin UI a stable home. For filesystem access inside a
project, still resolve project workspaces through `ctx.projects`.
@@ -300,6 +325,7 @@ Mount surfaces currently wired in the host include:
- `settingsPage`
- `dashboardWidget`
- `sidebar`
- `routeSidebar`
- `sidebarPanel`
- `detailTab`
- `taskDetailView`
@@ -317,6 +343,10 @@ Paperclip-native control. The host owns the implementation, so plugins inherit
the board's current styling, ordering, recent selections, and dark-mode behavior
without importing `ui/src` internals.
Prefer shared components for common Paperclip UX patterns to reduce drift and
deprecation risk, especially for task/assignment flows and routine or sidebar-like
plugin screens.
Currently exposed components include:
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
+88 -3
View File
@@ -319,7 +319,10 @@ export interface PaperclipPluginManifestV1 {
version: string;
displayName: string;
description: string;
author: string;
categories: Array<"connector" | "workspace" | "automation" | "ui">;
minimumHostVersion?: string;
/** @deprecated Use `minimumHostVersion` instead. Retained for backwards compatibility. */
minimumPaperclipVersion?: string;
capabilities: string[];
entrypoints: {
@@ -335,15 +338,42 @@ export interface PaperclipPluginManifestV1 {
description: string;
parametersSchema: JsonSchema;
}>;
database?: PluginDatabaseDeclaration;
apiRoutes?: PluginApiRouteDeclaration[];
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
agents?: PluginManagedAgentDeclaration[];
projects?: PluginManagedProjectDeclaration[];
routines?: PluginManagedRoutineDeclaration[];
skills?: PluginManagedSkillDeclaration[];
localFolders?: PluginLocalFolderDeclaration[];
/** Legacy top-level launcher declarations. Prefer `ui.launchers` for new manifests. */
launchers?: PluginLauncherDeclaration[];
ui?: {
launchers?: PluginLauncherDeclaration[];
slots: Array<{
type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage";
type: "page"
| "detailTab"
| "taskDetailView"
| "dashboardWidget"
| "sidebar"
| "routeSidebar"
| "sidebarPanel"
| "projectSidebarItem"
| "globalToolbarButton"
| "toolbarButton"
| "contextMenuItem"
| "commentAnnotation"
| "commentContextMenuItem"
| "settingsPage"
| "companySettingsPage";
id: string;
displayName: string;
/** Which export name in the UI bundle provides this component */
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
/** For page and companySettingsPage: single route segment */
routePath?: string;
}>;
};
}
@@ -354,10 +384,17 @@ Rules:
- `id` must be globally unique
- `id` should normally equal the npm package name
- `apiVersion` must match the host-supported plugin API version
- `minimumHostVersion` is preferred, with `minimumPaperclipVersion` retained for
backwards compatibility
- `capabilities` must be static and install-time visible
- config schema must be JSON Schema compatible
- `entrypoints.ui` points to the directory containing the built UI bundle
- `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle
- declare managed declarations with the matching `*.managed` capability:
- `agents``agents.managed`
- `projects``projects.managed`
- `routines``routines.managed`
- `skills``skills.managed`
## 11. Agent Tools
@@ -631,6 +668,22 @@ Plugins that need filesystem, git, terminal, or process operations handle those
Trusted orchestration plugins can create and update Paperclip issues through `ctx.issues` instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata.
Plugins that perform durable work should declare managed Paperclip resources rather than using private plugin state:
- `agents` + `ctx.agents.managed.*` for named, invokable operators (`agents.managed` required)
- `projects` + `ctx.projects.managed.*` for stable, scoped issue/workspace ownership (`projects.managed` required)
- `routines` + `ctx.routines.managed.*` for schedule/webhook/manual execution with issue trails (`routines.managed` required)
- `skills` + `ctx.skills.managed.*` for reusable agent capabilities (`skills.managed` required)
The LLM Wiki plugin is the current reference for this pattern: it declares managed
agents, projects, routines, and skills in manifest, reconciles them per company,
and uses managed routines for periodic wiki maintenance and ingest operations.
Content-oriented plugins should follow the same model instead of running
unmanaged background loops: make the LLM-facing worker an operator-visible
managed agent, attach reusable prompt/tool guidance as managed skills, keep
operation issues in a managed project, and drive recurring work through managed
routines.
Origin rules:
- Built-in core issues keep built-in origins such as `manual` and `routine_execution`.
@@ -746,20 +799,38 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
- `activity.read`
- `costs.read`
- `issues.orchestration.read`
- `database.namespace.read`
### Data Write
- `issues.create`
- `issues.update`
- `issue.comments.create`
- `issue.interactions.create`
- `issue.documents.write`
- `issue.relations.write`
- `issues.checkout`
- `issues.wakeup`
- `assets.write`
- `assets.read`
- `activity.log.write`
- `metrics.write`
- `telemetry.track`
- `assets.read`
- `assets.write`
- `database.namespace.migrate`
- `database.namespace.write`
- `goals.create`
- `goals.update`
- `projects.managed`
- `routines.managed`
- `skills.managed`
- `agents.managed`
- `agents.pause`
- `agents.resume`
- `agents.invoke`
- `agent.sessions.create`
- `agent.sessions.list`
- `agent.sessions.send`
- `agent.sessions.close`
### Plugin State
@@ -772,8 +843,10 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
- `events.emit`
- `jobs.schedule`
- `webhooks.receive`
- `local.folders`
- `http.outbound`
- `secrets.read-ref`
- `environment.drivers.register`
### Agent Tools
@@ -786,6 +859,7 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
- `ui.page.register`
- `ui.detailTab.register`
- `ui.dashboardWidget.register`
- `ui.commentAnnotation.register`
- `ui.action.register`
## 15.2 Forbidden Capabilities
@@ -894,6 +968,7 @@ Job rules:
3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later.
4. Every job run is recorded in Postgres.
5. Failed jobs are retryable.
6. For recurring business workflows that should create visible Paperclip work, prefer managed routines and managed resources over jobs. Jobs remain useful for private plugin-runtime maintenance tasks.
## 18. Webhooks
@@ -1134,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations.
## 20. Local Tooling
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
@@ -1383,6 +1460,14 @@ Each plugin may expose a company-context main page:
This page is where board users do most day-to-day work.
## 24.4 Company Settings Plugin Page
Each ready plugin may expose a company settings page:
- `/:companyPrefix/company/settings/:routePath`
The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages.
## 25. Uninstall And Data Lifecycle
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+38 -40
View File
@@ -6,7 +6,7 @@ Date: 2026-04-13
This document maps the current invite creation and acceptance states implemented in:
- `ui/src/pages/CompanyInvites.tsx`
- `ui/src/pages/CompanySettings.tsx`
- `ui/src/components/NewAgentDialog.tsx`
- `ui/src/pages/InviteLanding.tsx`
- `server/src/routes/access.ts`
- `server/src/lib/join-request-dedupe.ts`
@@ -23,16 +23,16 @@ This document maps the current invite creation and acceptance states implemented
```mermaid
flowchart TD
Board[Board user on invite screen]
Board[Board user on invite or add-agent screen]
HumanInvite[Create human company invite]
OpenClawInvite[Generate OpenClaw invite prompt]
AgentInvite[Generate agent onboarding prompt]
Active[Invite state: active]
Revoked[Invite state: revoked]
Expired[Invite state: expired]
Accepted[Invite state: accepted]
BootstrapDone[Bootstrap accepted<br/>no join request]
HumanReuse{Matching human join request<br/>already exists for same user/email?}
HumanPending[Join request<br/>pending_approval]
HumanPending[Legacy human join request<br/>pending_approval]
HumanApproved[Join request<br/>approved]
HumanRejected[Join request<br/>rejected]
AgentPending[Agent join request<br/>pending_approval<br/>+ optional claim secret]
@@ -44,7 +44,7 @@ flowchart TD
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>for openclaw_gateway only]
Board --> HumanInvite --> Active
Board --> OpenClawInvite --> Active
Board --> AgentInvite --> Active
Active --> Revoked: revoke
Active --> Expired: expiresAt passes
@@ -52,12 +52,10 @@ flowchart TD
BootstrapDone --> Accepted
Active --> HumanReuse: human accept
HumanReuse --> HumanPending: reuse existing pending request
HumanReuse --> HumanApproved: reuse existing approved request
HumanReuse --> HumanPending: no reusable request<br/>create new request
HumanPending --> HumanApproved: board approves
HumanReuse --> HumanApproved: reuse existing pending/approved request<br/>ensure active membership
HumanReuse --> HumanApproved: no reusable request<br/>create and approve request
HumanPending --> HumanApproved: same invitee replays accepted invite<br/>or board approves legacy request
HumanPending --> HumanRejected: board rejects
HumanPending --> Accepted
HumanApproved --> Accepted
Active --> AgentPending: agent accept
@@ -102,10 +100,10 @@ stateDiagram-v2
LatestInviteVisible --> Ready: navigate away or refresh
}
CompanySelection --> OpenClawPromptReady: Company settings prompt generator
OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt
OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated
OpenClawPromptPending --> OpenClawPromptReady: generation failed
CompanySelection --> AgentPromptReady: Add-agent modal prompt generator
AgentPromptReady --> AgentPromptPending: Generate agent onboarding prompt
AgentPromptPending --> AgentSnippetVisible: prompt generated
AgentPromptPending --> AgentPromptReady: generation failed
```
## Invite Landing Screen States
@@ -150,7 +148,8 @@ stateDiagram-v2
state AcceptedInviteSummary {
[*] --> SummaryBranch
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approved<br/>and membership missing
SummaryBranch --> PendingApprovalReload: agent joinRequestStatus=pending_approval
SummaryBranch --> OpeningCompany: joinRequestStatus=approved<br/>and human invite user is now a member
SummaryBranch --> RejectedReload: joinRequestStatus=rejected
SummaryBranch --> ConsumedReload: approved agent invite or other consumed state
@@ -177,6 +176,7 @@ sequenceDiagram
participant Landing as Invite landing UI
participant Auth as Auth session
participant Join as join_requests table
participant Membership as company_memberships + grants
Board->>Settings: Choose role and click Create invite
Settings->>API: POST /api/companies/:companyId/invites
@@ -197,15 +197,19 @@ sequenceDiagram
API->>Join: Look for reusable human join request
alt Reusable pending or approved request exists
API->>Invites: Mark invite accepted
API-->>Landing: Existing join request status
API->>Membership: Ensure active membership and role grants
API->>Join: Mark join request approved if needed
API-->>Landing: approved join request
else No reusable request exists
API->>Invites: Mark invite accepted
API->>Join: Insert pending_approval join request
API-->>Landing: New pending_approval join request
API->>Membership: Ensure active membership and role grants
API->>Join: Mark join request approved
API-->>Landing: approved join request
end
```
### Human Approval And Reload Path
### Legacy Human Reload And Repair Path
```mermaid
sequenceDiagram
@@ -214,8 +218,6 @@ sequenceDiagram
participant Landing as Invite landing UI
participant API as Access routes
participant Join as join_requests table
actor Approver as Company admin
participant Queue as Access queue UI
participant Membership as company_memberships + grants
Invitee->>Landing: Reload consumed invite URL
@@ -223,20 +225,15 @@ sequenceDiagram
API->>Join: Load join request by inviteId
API-->>Landing: joinRequestStatus + joinRequestType
alt joinRequestStatus = pending_approval
Landing-->>Invitee: Show waiting-for-approval panel
Approver->>Queue: Review request in Company Settings -> Access
Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve
API->>Membership: Ensure membership and grants
API->>Join: Mark join request approved
Invitee->>Landing: Refresh after approval
Landing->>API: GET /api/invites/:token
API->>Join: Reload approved join request
alt human joinRequestStatus = pending_approval or approved but membership missing
Landing->>API: POST /api/invites/:token/accept (requestType=human)
API->>Membership: Ensure active membership and role grants
API->>Join: Mark join request approved if needed
API-->>Landing: approved status
Landing-->>Invitee: Opening company and redirect
else joinRequestStatus = rejected
Landing-->>Invitee: Show rejected error panel
else joinRequestStatus = approved but membership missing
else agent invite or unavailable consumed state
Landing-->>Invitee: Fall through to consumed/unavailable state
end
```
@@ -247,21 +244,21 @@ sequenceDiagram
sequenceDiagram
autonumber
actor Board as Board user
participant Settings as Company Settings UI
participant AddAgent as Add agent modal
participant API as Access routes
participant Invites as invites table
actor Gateway as OpenClaw gateway agent
actor Gateway as External agent
participant Join as join_requests table
actor Approver as Company admin
participant Agents as agents table
participant Keys as agent_api_keys table
Board->>Settings: Generate OpenClaw invite prompt
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
Board->>AddAgent: Generate agent onboarding prompt
AddAgent->>API: POST /api/companies/:companyId/invites (allowedJoinTypes=agent)
API->>Invites: Insert active agent invite
API-->>Settings: Prompt text + invite token
API-->>AddAgent: Prompt text + invite token
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
Gateway->>API: POST /api/invites/:token/accept (agent, adapter-specific payload)
API->>Invites: Mark invite accepted
API->>Join: Insert pending_approval join request + claimSecretHash
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
@@ -286,14 +283,15 @@ sequenceDiagram
## Notes
- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`.
- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email.
- Human acceptance consumes the invite, creates or reuses the matching human join request, immediately marks it `approved`, and ensures an active company membership with the invite's selected role/grants.
- The landing page has two layers of post-accept UI:
- immediate mutation-result UI from `POST /api/invites/:token/accept`
- reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed
- Reload behavior for accepted company invites is now status-sensitive:
- `pending_approval` re-renders the waiting-for-approval panel
- human `pending_approval` or `approved` states replay acceptance for the same signed-in user/email so legacy consumed invites can repair missing membership
- agent `pending_approval` re-renders the waiting-for-approval panel
- `rejected` renders the "This join request was not approved." error panel
- `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state
- `approved` becomes a success path for human invites after membership is visible to the current session
- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`.
- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`.
- Accepted-invite replay is supported for matching human invitees to repair/complete membership, and for `agent` requests with `adapterType=openclaw_gateway` when the existing join request is still `pending_approval` or already `approved`.
- `bootstrap_ceo` invites are one-time and do not create join requests.
+17
View File
@@ -249,6 +249,23 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
4. **Last resort: prompt injection** — include skill content in the prompt template
## Cross-run workspace persistence (no-remote-git contract)
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
The supported round-trip:
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution``exportGitWorkspaceFromSsh``integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
The invariant adapters must preserve:
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
## Security
- Treat agent output as untrusted (parse defensively, never execute)
+23
View File
@@ -63,6 +63,29 @@ pnpm paperclipai agent list
pnpm paperclipai agent get <agent-id>
```
## Skills Commands
```sh
# Browse app-shipped catalog skills without changing company state
pnpm paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
pnpm paperclipai skills search "pull request" [--json]
# Inspect catalog metadata and file inventory before install
pnpm paperclipai skills inspect github-pr-workflow
# Install a catalog skill into the company skill library
# This does not attach the skill to any agent.
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
# External sources still use import instead of catalog install
pnpm paperclipai skills import ./skills/my-skill --company-id <company-id>
pnpm paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
# Attach desired company skills to an agent after install/import
pnpm paperclipai skills agent sync <agent-id> --skill github-pr-workflow --company-id <company-id>
```
## Approval Commands
```sh
@@ -64,6 +64,17 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
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.
## Cross-run persistence (no-remote-git contract)
Code state moves between runs through the local execution-workspace cwd alone — not through a git remote.
- Each run's prepare step bundles the local worktree to the run's remote dir over ssh, with no `git remote` configured.
- The adapter's restore step at the end of the run writes any new remote commits back into the local worktree directly.
- Adapters must never `git push` from runtime code, and must never assume a remote exists.
- A failed restore is a run-level error and records `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize.
The invariant is enforced by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`, which asserts a remote-only commit reaches the local worktree with no remote configured at any point.
## Current implementation guarantees
With the current implementation:
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+5 -3
View File
@@ -19,8 +19,8 @@
"test": "pnpm run test:run",
"test:watch": "pnpm run preflight:workspace-links && vitest",
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general",
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized",
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general",
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized",
"db:generate": "pnpm --filter @paperclipai/db generate",
"db:migrate": "pnpm --filter @paperclipai/db migrate",
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
@@ -35,12 +35,14 @@
"release:rollback": "./scripts/rollback-latest.sh",
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
+37
View File
@@ -0,0 +1,37 @@
# @paperclipai/adapter-utils
Shared utilities for Paperclip adapters: process spawning, environment
injection, sandbox/SSH transport, workspace sync, and the round-trip helpers
that move code between the local execution-workspace cwd and wherever the
agent actually runs.
For the adapter-author guide see
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md)
and the in-repo notes at [`packages/adapters/AUTHORING.md`](../adapters/AUTHORING.md).
## No-remote-git contract
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Adapters that run the agent on a different host should use the SSH round-trip
helpers in [`src/ssh.ts`](./src/ssh.ts):
- `prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })` — bundles
the local cwd (tracked files, dirty edits, untracked additions, and the git
history needed to reconstruct it) to `remoteDir` before the run starts. Runs
with no `git remote` configured.
- `restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })`
syncs the remote cwd back into `localDir` after the run, including any new
commits the agent created. Also runs with no `git remote` configured.
`prepareRemoteManagedRuntime` in
[`src/remote-managed-runtime.ts`](./src/remote-managed-runtime.ts) wraps both
calls for adapters that want a per-run remote workspace and an automatic
`restoreWorkspace()` finally hook.
The invariant is pinned by the `no-remote-git contract` case in
[`src/ssh-fixture.test.ts`](./src/ssh-fixture.test.ts), which asserts that a
remote-only commit propagates to the local worktree through the
prepare → restore round-trip with no git remote configured at any point. Do
not regress that test.
@@ -1,20 +1,57 @@
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
const COMMAND_CLI_SECRET_OPTION_RE =
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
const SECRET_NAME_PATTERN =
String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
const COMMAND_CLI_SECRET_OPTION_RE = new RegExp(
String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`,
"gi",
);
const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`,
"gi",
);
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
const COMMAND_JWT_RE =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
const COMMAND_SECRET_HINTS = [
"api",
"key",
"token",
"auth",
"bearer",
"secret",
"pass",
"credential",
"jwt",
"private",
"cookie",
"connectionstring",
"sk-",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
] as const;
function maybeContainsSecretText(command: string) {
const lower = command.toLowerCase();
return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes(".");
}
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
if (!maybeContainsSecretText(command)) return command;
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`)
.replace(
COMMAND_ENV_SECRET_ASSIGNMENT_RE,
(_match, prefix: string, quote: string | undefined) =>
quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`,
)
.replace(COMMAND_OPENAI_KEY_RE, redactedValue)
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
.replace(COMMAND_JWT_RE, redactedValue);
@@ -138,6 +138,13 @@ async function createTarballFromDirectory(input: {
const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
await execTar([
"-c",
// Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended
// headers for extended attributes (e.g. com.apple.provenance). GNU tar on
// Linux does not recognise these proprietary headers and fails extraction
// with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in
// execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs
// additionally suppresses the inline PAX xattr entries.
"--no-xattrs",
...(input.followSymlinks ? ["-h"] : []),
"-f",
input.archivePath,
+229 -2
View File
@@ -6,6 +6,8 @@ import { describe, expect, it } from "vitest";
import {
applyPaperclipWorkspaceEnv,
appendWithByteCap,
buildPersistentSkillSnapshot,
buildRuntimeMountedSkillSnapshot,
buildInvocationEnvForLogs,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
materializePaperclipSkillCopy,
@@ -53,13 +55,14 @@ describe("buildInvocationEnvForLogs", () => {
const loggedEnv = buildInvocationEnvForLogs(
{ SAFE_VALUE: "visible" },
{
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
resolvedCommand:
"env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret",
},
);
expect(loggedEnv.SAFE_VALUE).toBe("visible");
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***",
"env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***",
);
});
});
@@ -204,6 +207,186 @@ describe("materializePaperclipSkillCopy", () => {
});
});
describe("adapter skill snapshots", () => {
const requiredEntry = {
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
source: "/runtime/paperclip",
required: true,
requiredReason: "Required for Paperclip heartbeats.",
};
const optionalEntry = {
key: "company/ascii-heart",
runtimeName: "ascii-heart",
source: "/runtime/ascii-heart",
};
it("reports runtime-mounted adapters as configured or missing without install state", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "codex_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key, "missing-skill"],
configuredDetail: "Mounted on next run.",
});
expect(snapshot).toMatchObject({
supported: true,
mode: "ephemeral",
desiredSkills: [requiredEntry.key, "missing-skill"],
});
expect(snapshot.entries).toEqual([
expect.objectContaining({
key: "missing-skill",
state: "missing",
origin: "external_unknown",
desired: true,
}),
expect.objectContaining({
key: requiredEntry.key,
state: "configured",
origin: "paperclip_required",
required: true,
detail: "Mounted on next run.",
}),
]);
});
it("reports source-missing company runtime skills without orphan warnings", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "codex_local",
availableEntries: [{
key: "company/example/reflection-coach",
runtimeName: "reflection-coach--abc123",
source: "/paperclip/skills/example/__runtime__/reflection-coach--abc123",
sourceStatus: "missing",
missingDetail: "Company skill exists, but its local source is missing.",
}],
desiredSkills: ["company/example/reflection-coach"],
configuredDetail: "Mounted on next run.",
});
expect(snapshot.warnings).toEqual([]);
expect(snapshot.entries).toEqual([
expect.objectContaining({
key: "company/example/reflection-coach",
state: "missing",
origin: "company_managed",
sourcePath: null,
detail: "Company skill exists, but its local source is missing.",
}),
]);
});
it("keeps unsupported runtime-mounted adapters in tracked-only state", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "acpx_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key],
configuredDetail: "Mounted on next run.",
mode: "unsupported",
unsupportedDetail: "Tracked only.",
});
expect(snapshot.supported).toBe(false);
expect(snapshot.mode).toBe("unsupported");
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: requiredEntry.key,
desired: true,
state: "available",
detail: "Tracked only.",
}));
});
it("can surface read-only external skills for runtime-mounted adapters", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "claude_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key],
configuredDetail: "Mounted on next run.",
externalInstalled: new Map([
["crack-python", { targetPath: "/home/me/.claude/skills/crack-python", kind: "directory" }],
]),
externalLocationLabel: "~/.claude/skills",
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
});
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "crack-python",
runtimeName: "crack-python",
state: "external",
managed: false,
origin: "user_installed",
locationLabel: "~/.claude/skills",
readOnly: true,
}));
});
it("reports persistent adapter installed, stale, external, and missing states", () => {
const snapshot = buildPersistentSkillSnapshot({
adapterType: "cursor",
availableEntries: [requiredEntry, optionalEntry],
desiredSkills: [requiredEntry.key, "missing-skill"],
installed: new Map([
["paperclip", { targetPath: "/runtime/paperclip", kind: "symlink" }],
["ascii-heart", { targetPath: "/other/ascii-heart", kind: "directory" }],
["old-managed", { targetPath: "/runtime/old-managed", kind: "symlink" }],
]),
skillsHome: "/home/me/.cursor/skills",
locationLabel: "~/.cursor/skills",
installedDetail: "Installed in the Cursor skills home.",
missingDetail: "Configured but not linked.",
externalConflictDetail: "Name occupied externally.",
externalDetail: "Installed outside Paperclip management.",
});
expect(snapshot.mode).toBe("persistent");
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: requiredEntry.key,
state: "installed",
managed: true,
origin: "paperclip_required",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: optionalEntry.key,
state: "external",
managed: false,
detail: "Installed outside Paperclip management.",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "missing-skill",
state: "missing",
origin: "external_unknown",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "old-managed",
state: "external",
origin: "user_installed",
}));
});
it("reports stale managed persistent skills when Paperclip owns an undesired available skill", () => {
const snapshot = buildPersistentSkillSnapshot({
adapterType: "cursor",
availableEntries: [optionalEntry],
desiredSkills: [],
installed: new Map([
["ascii-heart", { targetPath: "/runtime/ascii-heart", kind: "symlink" }],
]),
skillsHome: "/home/me/.cursor/skills",
missingDetail: "Configured but not linked.",
externalConflictDetail: "Name occupied externally.",
externalDetail: "Installed outside Paperclip management.",
});
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: optionalEntry.key,
desired: false,
state: "stale",
managed: true,
}));
});
});
describe("runChildProcess", () => {
it("does not arm a timeout when timeoutSec is 0", async () => {
const result = await runChildProcess(
@@ -462,6 +645,50 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("named unblock owner/action");
});
it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => {
const title = "验证中文任务";
const commentBody = [
"请用中文回复。",
"日本語: 次の手順を書いてください。",
"हिन्दी: कृपया स्थिति बताएं।",
].join("\n");
const payload = {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-9452",
title,
status: "in_progress",
workMode: "standard",
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [
{
id: "comment-1",
body: commentBody,
author: { type: "user", id: "board-user-1" },
createdAt: "2026-05-15T16:30:00.000Z",
},
],
fallbackFetchNeeded: false,
};
const serialized = stringifyPaperclipWakePayload(payload);
expect(serialized).toContain(title);
expect(serialized).toContain("日本語");
expect(serialized).toContain("हिन्दी");
expect(JSON.parse(serialized ?? "{}")).toMatchObject({
issue: { title },
comments: [{ body: commentBody }],
});
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain(`- issue: PAP-9452 ${title}`);
expect(prompt).toContain(commentBody);
});
it("renders planning-mode directives for assignment and comment wakes", () => {
const assignmentPrompt = renderPaperclipWakePrompt({
reason: "issue_assigned",
+177
View File
@@ -133,6 +133,8 @@ export interface PaperclipSkillEntry {
key: string;
runtimeName: string;
source: string;
sourceStatus?: "available" | "missing";
missingDetail?: string | null;
required?: boolean;
requiredReason?: string | null;
}
@@ -161,6 +163,22 @@ interface PersistentSkillSnapshotOptions {
warnings?: string[];
}
interface RuntimeMountedSkillSnapshotOptions {
adapterType: string;
availableEntries: PaperclipSkillEntry[];
desiredSkills: string[];
configuredDetail: string | ((entry: PaperclipSkillEntry) => string | null);
missingDetail?: string;
mode?: "ephemeral" | "unsupported";
supported?: boolean;
unsupportedDetail?: string | ((entry: PaperclipSkillEntry) => string | null);
warnings?: string[];
externalInstalled?: Map<string, InstalledSkillTarget>;
externalLocationLabel?: string | null;
externalDetail?: string;
skillsHome?: string;
}
function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/");
}
@@ -193,6 +211,26 @@ function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
};
}
function isPaperclipSkillSourceMissing(entry: PaperclipSkillEntry) {
return entry.sourceStatus === "missing";
}
function resolvePaperclipSkillMissingDetail(
entry: PaperclipSkillEntry,
fallback: string,
) {
return entry.missingDetail?.trim() || fallback;
}
function resolveSkillDetail(
detail: string | ((entry: PaperclipSkillEntry) => string | null) | null | undefined,
entry: PaperclipSkillEntry,
): string | null {
if (typeof detail === "function") return detail(entry);
if (typeof detail === "string") return detail;
return null;
}
function resolveInstalledEntryTarget(
skillsHome: string,
entryName: string,
@@ -1381,6 +1419,120 @@ export async function readInstalledSkillTargets(skillsHome: string): Promise<Map
return out;
}
export function buildRuntimeMountedSkillSnapshot(
options: RuntimeMountedSkillSnapshotOptions,
): AdapterSkillSnapshot {
const {
adapterType,
availableEntries,
desiredSkills,
configuredDetail,
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
mode = "ephemeral",
externalInstalled,
externalLocationLabel,
externalDetail = "Installed outside Paperclip management.",
skillsHome,
} = options;
const supported = options.supported ?? mode !== "unsupported";
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = [];
const warnings = [...(options.warnings ?? [])];
for (const available of availableEntries) {
const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
const configured = supported && mode === "ephemeral" && desired;
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: configured ? "configured" : "available",
sourcePath: available.source,
targetPath: null,
detail: desired
? configured
? resolveSkillDetail(configuredDetail, available)
: resolveSkillDetail(
options.unsupportedDetail
?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
available,
)
: null,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
}
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: missingDetail,
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
});
}
if (externalInstalled) {
for (const [name, installedEntry] of externalInstalled.entries()) {
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: skillLocationLabel(externalLocationLabel),
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? (skillsHome ? path.join(skillsHome, name) : null),
detail: externalDetail,
});
}
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType,
supported,
mode,
desiredSkills,
entries,
warnings,
};
}
export function buildPersistentSkillSnapshot(
options: PersistentSkillSnapshotOptions,
): AdapterSkillSnapshot {
@@ -1404,6 +1556,26 @@ export function buildPersistentSkillSnapshot(
for (const available of availableEntries) {
const installedEntry = installed.get(available.runtimeName) ?? null;
const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, available.runtimeName),
detail: resolvePaperclipSkillMissingDetail(
available,
missingDetail,
),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
let state: AdapterSkillEntry["state"] = "available";
let managed = false;
let detail: string | null = null;
@@ -1496,6 +1668,11 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
key,
runtimeName,
source,
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
missingDetail:
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
? entry.missingDetail.trim()
: null,
required: asBoolean(entry.required, false),
requiredReason:
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
@@ -451,6 +451,68 @@ describe("ssh env-lab fixture", () => {
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
// Locks in the architectural contract documented in
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
// the local execution-workspace cwd is the only persistence boundary
// across runs. No adapter may depend on a git remote for cross-run state.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
// Assert there is no git remote configured before we begin, and verify
// that no point in the round-trip introduces one. `git remote` returns an
// empty string when no remotes exist (and exit code 0).
expect(await git(localRepo, ["remote"])).toBe("");
const started = await startSshEnvLabFixtureOrSkip(
statePath,
"no-remote-git contract test",
);
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-no-remote",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
// Remote commit lands a deliverable that must show up locally via
// sync-back alone — no `git push`, no fetch from any origin.
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
"remote-only commit",
);
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
"deliverable\n",
);
// Final assertion: still no git remote — restore did not silently add one.
expect(await git(localRepo, ["remote"])).toBe("");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("merges concurrent remote commits through the managed runtime restore path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
+58
View File
@@ -0,0 +1,58 @@
# Adapter Authoring Notes
In-repo notes for adapter authors. The user-facing guide lives at
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md);
this file holds invariants that are easy to violate from inside the adapter
package itself.
## No-remote-git contract (cross-run persistence)
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Why: Paperclip resolves a local execution workspace (a worktree) for each
heartbeat. Code state is carried forward by syncing that local cwd to wherever
the agent actually runs — over ssh, into a sandbox, into a managed runtime —
and then syncing changes back when the run finishes. Treating a `git remote`
as the source of truth (`git push` from inside the agent, fetch on the next
wake) breaks dependent issues that are gated on the local worktree being
caught up, and breaks isolated execution workspaces that have no remote
configured at all.
How to apply:
- Never `git push` from adapter runtime code. Never assume the local worktree
has any `git remote` configured. If you need data from the previous run,
read it from the local cwd Paperclip handed you.
- If your adapter runs the agent on a different host (ssh, sandbox, remote
container), use the round-trip helpers in `@paperclipai/adapter-utils`:
[`prepareWorkspaceForSshExecution`](../adapter-utils/src/ssh.ts) bundles the
local cwd to the remote dir before the run, and
[`restoreWorkspaceFromSshExecution`](../adapter-utils/src/ssh.ts) syncs
remote-side changes (including new git commits) back into the local cwd
after the run. Both run with no `git remote` configured.
- If your adapter runs the agent locally, you can read and write the cwd
directly — same invariant applies: changes that future runs need must live
in the local cwd by the time `execute()` returns.
- A failed sync-back is a run-level error. The heartbeat records
`workspace_finalize=failed` on the execution workspace, which gates
dependent issue wakes until the next successful finalize. Do not swallow
restore errors.
The invariant is pinned by the `no-remote-git contract` case in
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),
which asserts that a remote-only commit propagates to the local worktree
through `prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`
with no git remote configured at any point.
A static check enforces the rule before runtime ever sees it:
[`scripts/check-no-git-push.mjs`](../../scripts/check-no-git-push.mjs) scans
adapter and runtime source (`packages/adapters/`, `packages/adapter-utils/`,
`server/src/`, `cli/src/`) and fails the `policy` CI job if any unapproved
`git push` invocation is added. If you are building an operator-configured
path that legitimately must push, add a
`// paperclip:allow-git-push: <reason>` comment on the line (or the line
above) so the opt-in shows up in code review.
For the architecture-level write-up of cross-run persistence, see
[`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`](../../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#cross-run-persistence-no-remote-git-contract).
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AcpRuntimeOptions } from "acpx/runtime";
import { createAcpxLocalExecutor } from "./execute.js";
const tempRoots: string[] = [];
@@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => {
expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1);
});
it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const runStderrDir = path.join(stateDir, "run-stderr");
await fs.mkdir(runStderrDir, { recursive: true });
const stderrTail = "claude-agent-acp: SDK init failed (auth missing)";
await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8");
class FakeAcpRuntimeError extends Error {
readonly code = "ACP_SESSION_INIT_FAILED";
readonly cause: Error;
readonly retryable = false;
constructor(message: string, cause: Error) {
super(message);
this.name = "AcpRuntimeError";
this.cause = cause;
}
}
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => ({
ensureSession: async () => {
throw new FakeAcpRuntimeError(
"session/new failed: backend rejected initialize",
new Error("upstream timeout"),
);
},
startTurn: () => ({
events: (async function* () {})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "run-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
},
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_session_init_failed");
const meta = result.errorMeta ?? {};
expect(meta.errorName).toBe("AcpRuntimeError");
expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED");
expect(meta.causeMessage).toBe("upstream timeout");
expect(meta.retryable).toBe(false);
expect(typeof meta.stackPreview).toBe("string");
expect(meta.phase).toBe("ensure_session");
const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\""));
expect(errorLogLine).toBeTruthy();
const errorPayload = JSON.parse(errorLogLine!.text.trim());
expect(errorPayload.phase).toBe("ensure_session");
expect(errorPayload.errorName).toBe("AcpRuntimeError");
expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED");
expect(errorPayload.causeMessage).toBe("upstream timeout");
expect(errorPayload.childStderrTail).toContain("SDK init failed");
const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail"));
expect(stderrLog).toBeTruthy();
expect(stderrLog!.text).toContain(stderrTail);
});
it("writes wrapper that redirects child stderr to a per-run log file", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as AcpRuntimeOptions);
return buildRuntime() as never;
},
});
const result = await execute({
runId: "run-stderr-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose);
// verbose is scoped to the claude agent (PAPA-388); the custom agent here
// should not opt in to ACPX runtime verbose session-event logs.
expect(verboseFlags.every((flag) => flag === false)).toBe(true);
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
const wrapperFile = wrappers.find((name) => name.endsWith(".sh"));
expect(wrapperFile).toBeTruthy();
const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8");
expect(wrapper).toContain("stderr_dir=");
expect(wrapper).toContain("run-stderr");
expect(wrapper).toContain("PAPERCLIP_RUN_ID");
expect(wrapper).toContain("tee -a");
expect(wrapper).toContain("exec node ./fake-acp.js");
});
it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
let observedApiKeyDuringStream: string | undefined;
const execute = createAcpxLocalExecutor({
@@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => {
else process.env.PAPERCLIP_API_KEY = previousApiKey;
}
});
it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const { meta } = await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } },
);
const settingsPath = path.join(cwd, ".claude", "settings.local.json");
const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
permissions?: {
allow?: unknown;
additionalDirectories?: unknown;
defaultMode?: unknown;
};
};
expect(written.permissions?.defaultMode).toBe("default");
const allow = written.permissions?.allow;
expect(Array.isArray(allow)).toBe(true);
expect(allow).toContain("Bash(curl:*)");
expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`);
const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined;
expect(Array.isArray(additionalDirectories)).toBe(true);
expect(additionalDirectories).toContain(stateDir);
expect(additionalDirectories).toContain(path.join(root, "agent-home"));
const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
entry.includes("Paperclip-managed Claude settings"),
);
expect(note).toBeTruthy();
});
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
await fs.writeFile(
path.join(cwd, ".claude", "settings.local.json"),
JSON.stringify(
{
statusLine: { type: "command", command: "preserve-me" },
permissions: {
allow: ["Bash(npm test:*)"],
additionalDirectories: ["/Users/example/custom"],
defaultMode: "acceptEdits",
},
},
null,
2,
),
"utf8",
);
await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
const written = JSON.parse(
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
) as {
statusLine?: unknown;
permissions?: {
allow?: string[];
additionalDirectories?: string[];
defaultMode?: string;
};
};
expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" });
expect(written.permissions?.defaultMode).toBe("acceptEdits");
expect(written.permissions?.allow).toContain("Bash(npm test:*)");
expect(written.permissions?.allow).toContain("Bash(curl:*)");
expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom");
expect(written.permissions?.additionalDirectories).toContain(stateDir);
});
it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
await fs.writeFile(
path.join(cwd, ".claude", "settings.local.json"),
JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2),
"utf8",
);
const { meta } = await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
const written = JSON.parse(
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
) as { permissions?: { defaultMode?: string } };
expect(written.permissions?.defaultMode).toBe("default");
const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
entry.includes("overrode user dontAsk"),
);
expect(overrideNote).toBeTruthy();
});
it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => {
const root = await makeTempRoot();
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const verboseByAgent: Record<string, boolean | undefined> = {};
for (const agent of ["claude", "codex", "custom"] as const) {
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as AcpRuntimeOptions);
return buildRuntime() as never;
},
});
const result = await execute({
runId: `run-${agent}`,
agent: { id: `agent-${agent}`, companyId: "company-1" },
runtime: {},
config:
agent === "custom"
? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd }
: { agent, stateDir: path.join(root, `state-${agent}`), cwd },
context: { paperclipWorkspace: { cwd } },
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose;
}
expect(verboseByAgent.claude).toBe(true);
expect(verboseByAgent.codex).toBe(false);
expect(verboseByAgent.custom).toBe(false);
});
it("does not touch .claude/settings.local.json for the codex agent", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
await runExecutor(
{ agent: "codex", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
});
});
@@ -94,6 +94,8 @@ interface AcpxPreparedRuntime {
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
childStderrLogPath: string | null;
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
}
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
@@ -564,11 +566,105 @@ function buildSessionParams(input: {
};
}
interface PaperclipClaudeSettingsResult {
filePath: string;
allow: string[];
additionalDirectories: string[];
defaultMode: string;
overrodeDontAsk: boolean;
}
function uniqueSorted(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort();
}
// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses
// `settingSources: ["user", "project", "local"]`. By writing a per-worktree
// `.claude/settings.local.json` we override the user's potentially-restrictive
// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
// needs to talk to its own control plane.
async function writePaperclipClaudeSettings(input: {
cwd: string;
stateDir: string;
agentHome: string;
companyId: string;
}): Promise<PaperclipClaudeSettingsResult> {
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
const instanceRoot = defaultPaperclipInstanceDir();
const companyRoot = path.join(instanceRoot, "companies", input.companyId);
const paperclipAdditionalDirectories = uniqueSorted([
input.stateDir,
input.agentHome,
companyRoot,
]);
const paperclipAllow = uniqueSorted([
"Bash(curl:*)",
"Bash(env:*)",
"Bash(env)",
`Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`,
`Bash(${input.cwd}/scripts/paperclip:*)`,
]);
let existing: Record<string, unknown> = {};
const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null);
if (existingRaw) {
try {
const parsed = JSON.parse(existingRaw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record<string, unknown>;
} catch {
// Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one.
}
}
const existingPerms =
existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions)
? (existing.permissions as Record<string, unknown>)
: {};
const existingAllow = Array.isArray(existingPerms.allow)
? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string")
: [];
const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories)
? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string")
: [];
const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]);
const mergedAdditionalDirectories = uniqueSorted([
...existingAdditionalDirectories,
...paperclipAdditionalDirectories,
]);
const existingDefaultMode =
typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : "";
const defaultMode =
existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default";
const overrodeDontAsk = existingDefaultMode === "dontAsk";
const nextPermissions: Record<string, unknown> = {
...existingPerms,
allow: mergedAllow,
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
};
const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
await writeFileAtomically({
target: filePath,
contents: `${JSON.stringify(next, null, 2)}\n`,
mode: 0o600,
});
return {
filePath,
allow: mergedAllow,
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
overrodeDontAsk,
};
}
async function writeAgentWrapper(input: {
stateDir: string;
acpxAgent: string;
agentCommandShell: string;
env: Record<string, string>;
childStderrDir: string;
}): Promise<{ wrapperPath: string; envFilePath: string }> {
const wrappersDir = path.join(input.stateDir, "wrappers");
await fs.mkdir(wrappersDir, { recursive: true });
@@ -580,6 +676,7 @@ async function writeAgentWrapper(input: {
agent: input.acpxAgent,
command: input.agentCommandShell,
env: envLines,
childStderrDir: input.childStderrDir,
});
const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`);
const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`);
@@ -592,6 +689,11 @@ async function writeAgentWrapper(input: {
" source \"$env_file\"",
" set +a",
"fi",
`stderr_dir=${shellQuote(input.childStderrDir)}`,
"if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then",
" mkdir -p \"$stderr_dir\"",
" exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)",
"fi",
`exec ${input.agentCommandShell} "$@"`,
"",
].join("\n");
@@ -723,10 +825,20 @@ async function buildRuntime(input: {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler
// validates the value against its internal available-models list and rejects
// bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model
// entry in some versions. ANTHROPIC_MODEL is read during initialization, so
// it reliably sets the model before any turns are run.
if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) {
env.ANTHROPIC_MODEL = requestedModel;
}
let skillPromptInstructions = "";
let skillsIdentity: Record<string, unknown> = { mode: "unsupported" };
const skillCommandNotes: string[] = [];
let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null;
if (acpxAgent === "claude") {
const preparedSkills = await prepareClaudeSkillRuntime({
stateDir,
@@ -736,6 +848,17 @@ async function buildRuntime(input: {
skillPromptInstructions = preparedSkills.promptInstructions;
skillsIdentity = preparedSkills.identity;
skillCommandNotes.push(...preparedSkills.commandNotes);
paperclipClaudeSettings = await writePaperclipClaudeSettings({
cwd,
stateDir,
agentHome,
companyId: agent.companyId,
});
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
}, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
);
} else if (acpxAgent === "codex") {
const preparedSkills = await prepareCodexSkillRuntime({
companyId: agent.companyId,
@@ -757,12 +880,15 @@ async function buildRuntime(input: {
const builtInCommand = resolveBuiltInAgentCommand(acpxAgent);
const agentCommand = configuredCommand || builtInCommand || null;
const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : "");
const childStderrDir = path.join(stateDir, "run-stderr");
const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null;
const wrapper = agentCommand
? await writeAgentWrapper({
stateDir,
acpxAgent,
agentCommandShell,
env,
childStderrDir,
})
: null;
const wrapperPath = wrapper?.wrapperPath ?? null;
@@ -781,6 +907,13 @@ async function buildRuntime(input: {
remoteExecutionIdentity,
skillsIdentity,
skillPromptInstructions,
paperclipClaudeSettings: paperclipClaudeSettings
? {
allow: paperclipClaudeSettings.allow,
additionalDirectories: paperclipClaudeSettings.additionalDirectories,
defaultMode: paperclipClaudeSettings.defaultMode,
}
: null,
});
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
@@ -817,12 +950,19 @@ async function buildRuntime(input: {
...skillsIdentity,
commandNotes: skillCommandNotes,
},
childStderrLogPath,
paperclipClaudeSettings,
};
}
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
// Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at
// startup; skip set_config_option to avoid ACP-server model-name validation
// that rejects bare IDs like "claude-opus-4-7" in some runtime versions.
if (prepared.requestedModel && prepared.acpxAgent !== "claude") {
options.push({ key: "model", value: prepared.requestedModel });
}
if (prepared.requestedThinkingEffort) {
options.push({
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
@@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null {
return result.error.message;
}
function classifyError(err: unknown): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
const message = err instanceof Error ? err.message : String(err);
type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn";
function describeErrorDiagnostics(err: unknown): {
errorName: string;
acpCode: string | null;
causeMessage: string | null;
retryable: boolean | null;
stackPreview: string | null;
} {
const errorName =
err instanceof Error ? err.name || err.constructor.name : typeof err;
const maybeCode =
err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string"
? (err as { code: string }).code
: null;
const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
const acpCode =
isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
const cause =
err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined
? (err as { cause?: unknown }).cause
: undefined;
const causeMessage =
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: null;
const retryable =
err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean"
? (err as { retryable: boolean }).retryable
: null;
const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : "";
const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null;
return { errorName, acpCode, causeMessage, retryable, stackPreview };
}
function classifyError(
err: unknown,
phase?: AcpxExecutionPhase,
): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
const message = err instanceof Error ? err.message : String(err);
const diagnostics = describeErrorDiagnostics(err);
const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics;
const baseMeta: Record<string, unknown> = {
errorName,
...(acpCode ? { acpCode } : {}),
...(causeMessage ? { causeMessage } : {}),
...(retryable !== null ? { retryable } : {}),
...(stackPreview ? { stackPreview } : {}),
...(phase ? { phase } : {}),
};
const lower = message.toLowerCase();
const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential");
if (authLike) {
return {
errorCode: "acpx_auth_required",
errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) },
errorMeta: { category: "auth", ...baseMeta },
};
}
const phaseCode = (() => {
if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed";
if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed";
if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing";
if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable";
if (phase === "ensure_session") return "acpx_session_init_failed";
if (phase === "configure_session") return "acpx_session_config_failed";
if (phase === "turn") return "acpx_turn_failed";
return null;
})();
if (phaseCode) {
return {
errorCode: phaseCode,
errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta },
};
}
if (acpCode) {
return {
errorCode: "acpx_protocol_error",
errorMeta: { category: "protocol", acpCode },
errorMeta: { category: "protocol", ...baseMeta },
};
}
return {
errorCode: "acpx_runtime_error",
errorMeta: { category: "runtime" },
errorMeta: { category: "runtime", ...baseMeta },
};
}
async function readChildStderrTail(input: {
logPath: string | null;
maxBytes?: number;
}): Promise<string | null> {
if (!input.logPath) return null;
const maxBytes = input.maxBytes ?? 4096;
let handle: fs.FileHandle | null = null;
try {
const stat = await fs.stat(input.logPath);
if (stat.size === 0) return null;
handle = await fs.open(input.logPath, "r");
const readBytes = Math.min(stat.size, maxBytes);
const buffer = Buffer.alloc(readBytes);
await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes));
const tail = buffer.toString("utf8").trim();
return tail.length > 0 ? tail : null;
} catch {
return null;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function emitAcpxFailure(input: {
ctx: AdapterExecutionContext;
prepared: AcpxPreparedRuntime;
err: unknown;
phase: AcpxExecutionPhase;
// Replace the err-derived message in both the stderr-tail log header and the
// acpx.error payload. Used by the turn path to surface "Timed out after Ns"
// instead of the raw underlying error message.
messageOverride?: string;
}): Promise<{
classified: Pick<AdapterExecutionResult, "errorCode" | "errorMeta">;
message: string;
childStderrTail: string | null;
}> {
const { ctx, prepared, err, phase, messageOverride } = input;
const rawMessage = err instanceof Error ? err.message : String(err);
const message = messageOverride ?? rawMessage;
const classified = classifyError(err, phase);
const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath });
if (childStderrTail) {
await ctx.onLog(
"stderr",
`[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`,
);
}
await emitAcpxLog(ctx, {
type: "acpx.error",
message,
phase,
...classified.errorMeta,
...(childStderrTail ? { childStderrTail } : {}),
});
return { classified, message, childStderrTail };
}
function isResumeFailure(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return /resume|load|not found|no session|unknown session|conversation/i.test(message);
@@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
permissionMode: prepared.permissionMode,
nonInteractivePermissions: prepared.nonInteractivePermissions,
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
// Scope ACPX runtime verbose logs to the claude agent only — that's the
// surface we know needs the extra session-event detail (PAPA-388). codex
// and custom agents already emit their own per-tool output and don't
// benefit from doubling the log volume.
verbose: prepared.acpxAgent === "claude",
};
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
if (cached) clearWarmHandleTimer(cached);
@@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
}
}
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "ensure_session",
});
return {
exitCode: 1,
signal: null,
@@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
onLog: ctx.onLog,
});
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "configure_session",
});
await runtime.close({
handle: sessionHandle,
reason: "paperclip config cleanup",
@@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
commandNotes: [
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
...(prepared.requestedModel
? [
prepared.acpxAgent === "claude"
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
: `Requested ACPX model: ${prepared.requestedModel}.`,
]
: []),
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
...(Array.isArray(prepared.skillsIdentity.commandNotes)
@@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
};
} catch (err) {
if (timeout) clearTimeout(timeout);
const classified = classifyError(err);
const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err);
const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined;
const cancel = cancelActiveTurn as ((reason: string) => Promise<void>) | null;
if (cancel) await cancel(message).catch(() => {});
const preEmitMessage =
messageOverride ?? (err instanceof Error ? err.message : String(err));
if (cancel) await cancel(preEmitMessage).catch(() => {});
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
@@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "turn",
messageOverride,
});
return {
exitCode: 1,
signal: timedOut ? "SIGTERM" : null,
@@ -2,10 +2,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
@@ -35,9 +35,7 @@ function unsupportedDetail(): string {
async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const acpxAgent = normalizeAcpxSkillAgent(config);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const supported = acpxAgent !== "custom";
const warnings: string[] = supported
? []
@@ -45,53 +43,16 @@ async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
];
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => {
const desired = desiredSet.has(entry.key);
return {
key: entry.key,
runtimeName: entry.runtimeName,
desired,
managed: true,
state: desired ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desired ? (supported ? configuredDetail(acpxAgent) : unsupportedDetail()) : null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
};
});
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
return buildRuntimeMountedSkillSnapshot({
adapterType: "acpx_local",
availableEntries,
desiredSkills,
supported,
mode: supported ? "ephemeral" : "unsupported",
desiredSkills,
entries,
configuredDetail: configuredDetail(acpxAgent),
unsupportedDetail: unsupportedDetail(),
warnings,
};
});
}
export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -6,6 +6,7 @@ export const label = "Claude Code (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
export const models = [
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
@@ -1,6 +1,6 @@
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
export { listClaudeModels } from "./models.js";
export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js";
export { testEnvironment } from "./test.js";
export {
parseClaudeStreamJson,
@@ -1,13 +1,22 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { models as DIRECT_MODELS } from "../index.js";
const ANTHROPIC_MODELS_ENDPOINT = "/v1/models";
const ANTHROPIC_MODELS_TIMEOUT_MS = 5000;
const ANTHROPIC_MODELS_CACHE_TTL_MS = 60_000;
const ANTHROPIC_API_VERSION = "2023-06-01";
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
];
let cached: { keyFingerprint: string; baseUrl: string; expiresAt: number; models: AdapterModel[] } | null = null;
function isBedrockEnv(): boolean {
return (
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
@@ -17,13 +26,134 @@ function isBedrockEnv(): boolean {
);
}
function fingerprint(apiKey: string): string {
const digest = createHash("sha256").update(apiKey).digest("base64url").slice(0, 16);
return `${apiKey.length}:${digest}`;
}
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
return dedupeModels([
...models,
...DIRECT_MODELS,
]);
}
function resolveAnthropicApiKey(): string | null {
const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
return apiKey && apiKey.length > 0 ? apiKey : null;
}
function resolveAnthropicBaseUrl(): string {
const baseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
return baseUrl && baseUrl.length > 0 ? baseUrl.replace(/\/+$/, "") : "https://api.anthropic.com";
}
async function fetchAnthropicModels(apiKey: string, baseUrl: string): Promise<AdapterModel[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ANTHROPIC_MODELS_TIMEOUT_MS);
try {
const response = await fetch(`${baseUrl}${ANTHROPIC_MODELS_ENDPOINT}`, {
headers: {
"anthropic-version": ANTHROPIC_API_VERSION,
"x-api-key": apiKey,
},
signal: controller.signal,
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const data = Array.isArray(payload.data) ? payload.data : [];
const models: AdapterModel[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as { id?: unknown; display_name?: unknown };
if (typeof record.id !== "string" || record.id.trim().length === 0) continue;
const displayName =
typeof record.display_name === "string" && record.display_name.trim().length > 0
? record.display_name
: record.id;
models.push({
id: record.id,
label: displayName,
});
}
return dedupeModels(models);
} catch (error) {
console.warn("[paperclip] Claude model discovery failed", {
error: error instanceof Error ? error.message : String(error),
});
return [];
} finally {
clearTimeout(timeout);
}
}
async function loadClaudeModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
if (isBedrockEnv()) return dedupeModels(BEDROCK_MODELS);
const fallback = dedupeModels(DIRECT_MODELS);
const apiKey = resolveAnthropicApiKey();
if (!apiKey) return fallback;
const now = Date.now();
const baseUrl = resolveAnthropicBaseUrl();
const keyFingerprint = fingerprint(apiKey);
if (
options?.forceRefresh !== true &&
cached &&
cached.keyFingerprint === keyFingerprint &&
cached.baseUrl === baseUrl &&
cached.expiresAt > now
) {
return cached.models;
}
const fetched = await fetchAnthropicModels(apiKey, baseUrl);
if (fetched.length > 0) {
const merged = mergedWithFallback(fetched);
cached = {
keyFingerprint,
baseUrl,
expiresAt: now + ANTHROPIC_MODELS_CACHE_TTL_MS,
models: merged,
};
return merged;
}
if (cached && cached.keyFingerprint === keyFingerprint && cached.baseUrl === baseUrl && cached.models.length > 0) {
return cached.models;
}
return fallback;
}
/**
* Return the model list appropriate for the current auth mode.
* When Bedrock env vars are detected, returns Bedrock-native model IDs;
* otherwise returns standard Anthropic API model IDs.
*/
export async function listClaudeModels(): Promise<AdapterModel[]> {
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
return loadClaudeModels();
}
export async function refreshClaudeModels(): Promise<AdapterModel[]> {
return loadClaudeModels({ forceRefresh: true });
}
export function resetClaudeModelsCacheForTests() {
cached = null;
}
/** Check whether a model ID is a Bedrock-native identifier (not an Anthropic API short name). */
@@ -3,10 +3,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
readInstalledSkillTargets,
resolvePaperclipDesiredSkillNames,
@@ -30,76 +30,19 @@ function resolveClaudeSkillsHome(config: Record<string, unknown>) {
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveClaudeSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: undefined,
targetPath: undefined,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: "~/.claude/skills",
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
detail: "Installed outside Paperclip management in the Claude skills home.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
return buildRuntimeMountedSkillSnapshot({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
availableEntries,
desiredSkills,
entries,
warnings,
};
configuredDetail: "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run.",
externalInstalled: installed,
externalLocationLabel: "~/.claude/skills",
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
skillsHome,
});
}
export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -212,6 +212,14 @@ export async function testEnvironment(
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (extraArgs.length > 0) args.push(...extraArgs);
// Sandbox bridges still add lease warmup and transport overhead, but
// the standard-2 Cloudflare tier now probes fast enough that a 90s
// budget leaves headroom without masking real hangs.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -220,7 +228,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
+2 -1
View File
@@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "low",
// Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work.
modelReasoningEffort: "high",
},
source: "adapter_default",
},
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareManagedCodexHome } from "./codex-home.js";
describe("codex managed home", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("treats a concurrently-created expected auth symlink as success", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
await originalSymlink(source, target, type);
const error = new Error("file already exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
});
try {
await expect(
prepareManagedCodexHome(
{
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
},
async () => {},
"company-1",
),
).resolves.toBe(managedCodexHome);
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) return false;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return false;
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
}
async function createExpectedSymlink(target: string, source: string): Promise<void> {
try {
await fs.symlink(source, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
throw error;
}
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
@@ -2,10 +2,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
@@ -16,56 +16,13 @@ async function buildCodexSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
return buildRuntimeMountedSkillSnapshot({
adapterType: "codex_local",
supported: true,
mode: "ephemeral",
availableEntries,
desiredSkills,
entries,
warnings,
};
configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.",
});
}
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -4,6 +4,7 @@ import type {
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asNumber,
asString,
asStringArray,
parseObject,
@@ -98,6 +99,7 @@ export async function testEnvironment(
let command = asString(config.command, "agent");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -230,6 +232,12 @@ export async function testEnvironment(
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
});
} else {
// Cursor's `agent` binary still pays cold-start overhead in container
// sandboxes, but standard-2 probes no longer need a 120s version budget.
const versionProbeTimeoutSec = Math.max(
1,
asNumber(config.versionProbeTimeoutSec, targetIsSandbox ? 60 : 45),
);
const versionProbe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -238,7 +246,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: versionProbeTimeoutSec,
graceSec: 5,
onLog: async () => {},
},
@@ -295,6 +303,12 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
// Sandbox bridges still add cursor CLI cold-start overhead, but the
// standard-2 tier now completes probes fast enough that 90s is ample.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -303,7 +317,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
onLog: async () => {},
},
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@paperclipai/adapter-grok-local",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/grok-local"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { printGrokStreamEvent } from "./format-event.js";
describe("printGrokStreamEvent", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
afterEach(() => {
spy.mockClear();
});
it("prints thought/text/end events", () => {
printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false);
printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false);
printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false);
expect(spy.mock.calls.flat()).toEqual(
expect.arrayContaining([
expect.stringContaining("thinking: Plan"),
expect.stringContaining("assistant: hello"),
expect.stringContaining("Grok run completed"),
]),
);
});
});
@@ -0,0 +1,59 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function printGrokStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
console.log(line);
return;
}
const type = asString(parsed.type).trim();
if (type === "thought") {
const text = asString(parsed.data);
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "text") {
const text = asString(parsed.data);
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "end") {
const stopReason = asString(parsed.stopReason);
const sessionId = asString(parsed.sessionId);
const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""]
.filter(Boolean)
.join(" ");
console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`));
return;
}
if (type === "error") {
const text =
asString(parsed.data) ||
asString(parsed.message) ||
asString(parsed.error) ||
"Grok error";
console.log(pc.red(`error: ${text}`));
return;
}
const payload = asRecord(parsed);
console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`));
}
@@ -0,0 +1 @@
export { printGrokStreamEvent } from "./format-event.js";
+45
View File
@@ -0,0 +1,45 @@
export const type = "grok_local";
export const label = "Grok Build (local)";
export const DEFAULT_GROK_LOCAL_MODEL = "grok-build";
export const models = [
{ id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL },
];
export const agentConfigurationDoc = `# grok_local agent configuration
Adapter: grok_local
Use when:
- You want Paperclip to run the native Grok Build CLI locally on the host machine
- You want resumable Grok sessions across heartbeats via \`--resume\`
- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`)
Don't use when:
- You need a webhook-style external invocation (use http or openclaw_gateway)
- You only need a one-shot script without an AI coding agent loop (use process)
- Grok CLI is not installed or authenticated on the machine that runs Paperclip
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. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\`
- promptTemplate (string, optional): run prompt template
- model (string, optional): Grok model id. Defaults to grok-build.
- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\`
- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\`
- maxTurns (number, optional): maximum agent turns for the run
- command (string, optional): defaults to "grok"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Runs use \`grok --single\` with \`--output-format streaming-json\`.
- Sessions resume with \`--resume <sessionId>\` when the saved session cwd matches the current cwd.
- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills.
- Use \`grok models\` to inspect authentication and available models on the host.
`;
@@ -0,0 +1,187 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({
workspaceRemoteDir: null,
restoreWorkspace: async () => {},
})));
const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok"));
const runProcessMock = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
adapterExecutionTargetIsRemote: () => false,
adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd,
overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target,
adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }),
adapterExecutionTargetSessionMatches: () => true,
describeAdapterExecutionTarget: () => "local",
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock,
prepareAdapterExecutionTargetRuntime: prepareRuntimeMock,
readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" },
resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock,
resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec,
runAdapterExecutionTargetProcess: runProcessMock,
}));
import { execute } from "./execute.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-"));
tempRoots.push(root);
return root;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
describe("grok_local execute", () => {
beforeEach(() => {
ensureRuntimeInstalledMock.mockClear();
ensureCommandMock.mockClear();
prepareRuntimeMock.mockClear();
resolveCommandForLogsMock.mockClear();
runProcessMock.mockReset();
});
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => {
const root = await makeTempRoot();
const instructionsPath = path.join(root, "managed", "AGENTS.md");
const skillSource = path.join(root, "runtime-skills", "paperclip");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => {
expect(args).toEqual(
expect.arrayContaining([
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
]),
);
expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok.");
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true);
await options.onLog?.("stdout", '{"type":"text","data":"done"}\n');
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "text", data: "done" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"),
stderr: "",
};
});
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
const ctx: AdapterExecutionContext = {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Grok Agent",
adapterType: "grok_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
cwd: root,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{
key: "paperclip",
runtimeName: "paperclip",
source: skillSource,
required: false,
}],
paperclipSkillSync: { desiredSkills: ["paperclip"] },
},
context: {},
authToken: "run-token",
onLog: async (stream: "stdout" | "stderr", chunk: string) => {
logs.push({ stream, chunk });
},
};
const result = await execute(ctx);
expect(result).toMatchObject({
exitCode: 0,
errorMessage: null,
summary: "done",
sessionId: "sess-1",
sessionDisplayId: "sess-1",
});
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
expect(logs.map((entry) => entry.chunk)).not.toEqual([]);
});
it("cleans up staged assets when setup fails before the Grok process starts", async () => {
const root = await makeTempRoot();
const instructionsPath = path.join(root, "managed", "AGENTS.md");
const skillSource = path.join(root, "runtime-skills", "paperclip");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed"));
const ctx: AdapterExecutionContext = {
runId: "run-setup-fail",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Grok Agent",
adapterType: "grok_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
cwd: root,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{
key: "paperclip",
runtimeName: "paperclip",
source: skillSource,
required: false,
}],
paperclipSkillSync: { desiredSkills: ["paperclip"] },
},
context: {},
authToken: "run-token",
onLog: async () => {},
};
await expect(execute(ctx)).rejects.toThrow("grok not installed");
expect(runProcessMock).not.toHaveBeenCalled();
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
});
});
@@ -0,0 +1,583 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
overrideAdapterExecutionTargetRemoteCwd,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
resolveAdapterExecutionTargetTimeoutSec,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
asNumber,
asString,
asStringArray,
buildInvocationEnvForLogs,
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensurePathInEnv,
joinPromptSections,
materializePaperclipSkillCopy,
parseObject,
readPaperclipIssueWorkModeFromContext,
readPaperclipRuntimeSkillEntries,
renderTemplate,
renderPaperclipWakePrompt,
resolvePaperclipDesiredSkillNames,
stringifyPaperclipWakePayload,
refreshPaperclipWorkspaceEnvForExecution,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (paperclipKeys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
return [
"Paperclip API access note:",
"Use shell commands with curl to make Paperclip API requests when needed.",
"Include X-Paperclip-Run-Id on mutating requests.",
"",
"",
].join("\n");
}
type StageCleanup = {
kind: "file" | "dir";
path: string;
};
type StagedGrokAssets = {
cleanup: () => Promise<void>;
stagedSkillsCount: number;
stagedInstructionsPath: string | null;
rulesFilePath: string | null;
};
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
async function stageGrokProjectAssets(input: {
cwd: string;
instructionsFilePath: string;
skillEntries: Array<{ key: string; runtimeName: string; source: string }>;
desiredSkillNames: string[];
onLog: AdapterExecutionContext["onLog"];
}): Promise<StagedGrokAssets> {
const cleanup: StageCleanup[] = [];
const ensureCleanupDir = (candidate: string) => {
cleanup.push({ kind: "dir", path: candidate });
};
const ensureCleanupFile = (candidate: string) => {
cleanup.push({ kind: "file", path: candidate });
};
let stagedInstructionsPath: string | null = null;
let rulesFilePath: string | null = null;
let stagedSkillsCount = 0;
const instructionsTarget = path.join(input.cwd, "Agents.md");
if (input.instructionsFilePath) {
if (!await pathExists(instructionsTarget)) {
await fs.copyFile(input.instructionsFilePath, instructionsTarget);
ensureCleanupFile(instructionsTarget);
stagedInstructionsPath = instructionsTarget;
} else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) {
rulesFilePath = input.instructionsFilePath;
await input.onLog(
"stdout",
`[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`,
);
}
} else {
const canonicalAgents = path.join(input.cwd, "AGENTS.md");
if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) {
await fs.copyFile(canonicalAgents, instructionsTarget);
ensureCleanupFile(instructionsTarget);
stagedInstructionsPath = instructionsTarget;
}
}
const desiredSet = new Set(input.desiredSkillNames);
const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key));
if (selectedSkills.length > 0) {
const claudeDir = path.join(input.cwd, ".claude");
const skillsRoot = path.join(claudeDir, "skills");
if (!await pathExists(claudeDir)) {
await fs.mkdir(claudeDir, { recursive: true });
ensureCleanupDir(claudeDir);
}
if (!await pathExists(skillsRoot)) {
await fs.mkdir(skillsRoot, { recursive: true });
ensureCleanupDir(skillsRoot);
}
for (const skill of selectedSkills) {
const target = path.join(skillsRoot, skill.runtimeName);
if (await pathExists(target)) {
await input.onLog(
"stdout",
`[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`,
);
continue;
}
await materializePaperclipSkillCopy(skill.source, target);
ensureCleanupDir(target);
stagedSkillsCount += 1;
}
}
return {
stagedSkillsCount,
stagedInstructionsPath,
rulesFilePath,
cleanup: async () => {
for (const entry of [...cleanup].reverse()) {
if (entry.kind === "file") {
await fs.rm(entry.path, { force: true }).catch(() => undefined);
continue;
}
await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined);
}
},
};
}
function resolveBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription";
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "grok");
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk";
const reasoningEffort = asString(config.reasoningEffort, "").trim();
const maxTurns = asNumber(config.maxTurns, 0);
const alwaysApprove = asBoolean(config.alwaysApprove, true);
const disableWebSearch = asBoolean(config.disableWebSearch, true);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const agentHome = asString(workspaceContext.agentHome, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const stagedAssets = await stageGrokProjectAssets({
cwd,
instructionsFilePath,
skillEntries: grokSkillEntries,
desiredSkillNames: desiredGrokSkillNames,
onLog,
});
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
try {
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
if (executionTargetIsRemote) {
await onLog(
"stdout",
`[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "grok",
workspaceLocalDir: cwd,
timeoutSec,
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command,
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const billingType = resolveBillingType(effectiveEnv);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
);
} else if (runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
);
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."];
if (alwaysApprove) notes.push("Added --always-approve for unattended execution.");
if (stagedAssets.stagedInstructionsPath) {
notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`);
}
if (stagedAssets.rulesFilePath) {
notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`);
}
if (stagedAssets.stagedSkillsCount > 0) {
notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`);
}
return notes;
})();
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
wakePrompt,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model);
if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort);
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (permissionMode) args.push("--permission-mode", permissionMode);
if (alwaysApprove) args.push("--always-approve");
if (disableWebSearch) args.push("--disable-web-search");
if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("--single", prompt);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "grok_local",
command: resolvedCommand,
cwd: effectiveExecutionCwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: loggedEnv,
prompt,
promptMetrics,
context,
});
}
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onSpawn,
onLog,
});
return {
proc,
parsed: parseGrokJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
parsed: ReturnType<typeof parseGrokJsonl>;
},
clearSessionOnMissingSession = false,
isRetry = 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 failed = (attempt.proc.exitCode ?? 0) !== 0;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`Grok exited with code ${attempt.proc.exitCode ?? -1}`;
const canFallbackToRuntimeSession = !isRetry;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd: effectiveExecutionCwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
: null;
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: failed ? fallbackErrorMessage : null,
usage: {
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "xai",
biller: billingType === "api" ? "xai" : "grok",
model,
billingType,
costUsd: null,
resultJson: {
stopReason: attempt.parsed.stopReason,
requestId: attempt.parsed.requestId,
...(failed ? { stderr: attempt.proc.stderr } : {}),
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stdout",
`[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true, true);
}
return toResult(initial);
} finally {
await Promise.all([
restoreRemoteWorkspace?.(),
stagedAssets.cleanup(),
]);
}
}
@@ -0,0 +1,66 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};
export { execute } from "./execute.js";
export { listGrokSkills, syncGrokSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js";
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
describe("parseGrokJsonl", () => {
it("collects streamed thought/text content and final session metadata", () => {
const parsed = parseGrokJsonl([
JSON.stringify({ type: "thought", data: "Plan" }),
JSON.stringify({ type: "thought", data: " first." }),
JSON.stringify({ type: "text", data: "hel" }),
JSON.stringify({ type: "text", data: "lo" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"));
expect(parsed).toEqual({
sessionId: "sess-1",
summary: "hello",
thought: "Plan first.",
errorMessage: null,
stopReason: "EndTurn",
requestId: "req-1",
});
});
it("reads structured error payloads", () => {
const parsed = parseGrokJsonl([
JSON.stringify({ type: "error", error: { message: "Authentication required" } }),
].join("\n"));
expect(parsed.errorMessage).toBe("Authentication required");
});
it("separates reasoning turns that grok streaming-json glues together", () => {
// PAPA-349: at turn boundaries grok drops the newline between turns; the
// aggregated thought should still read as two paragraphs.
const parsed = parseGrokJsonl([
JSON.stringify({ type: "thought", data: "The user uses `" }),
JSON.stringify({ type: "thought", data: "ls" }),
JSON.stringify({ type: "thought", data: "`" }),
JSON.stringify({ type: "thought", data: "The" }),
JSON.stringify({ type: "thought", data: " `" }),
JSON.stringify({ type: "thought", data: "ls" }),
JSON.stringify({ type: "thought", data: "`" }),
JSON.stringify({ type: "thought", data: " returned" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
].join("\n"));
expect(parsed.thought).toBe("The user uses `ls`\nThe `ls` returned");
});
it("preserves assistant `text` chunks verbatim (no boundary heuristic)", () => {
// PAPA-349 review feedback: the turn-boundary helper is scoped to the
// reasoning stream only. Final assistant text is stored unmodified so
// user-visible responses cannot be reshaped by the heuristic.
const parsed = parseGrokJsonl([
JSON.stringify({ type: "text", data: "Done." }),
JSON.stringify({ type: "text", data: "Next" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
].join("\n"));
expect(parsed.summary).toBe("Done.Next");
});
});
describe("isGrokUnknownSessionError", () => {
it("detects stale resume failures", () => {
expect(isGrokUnknownSessionError("", "session not found")).toBe(true);
expect(isGrokUnknownSessionError("", "everything fine")).toBe(false);
});
});
@@ -0,0 +1,89 @@
import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { applyTurnBoundary, createTurnBoundaryState } from "../shared/turn-boundary.js";
export interface ParsedGrokJsonl {
sessionId: string | null;
summary: string;
thought: string;
errorMessage: string | null;
stopReason: string | null;
requestId: string | null;
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message =
asString(rec.message, "").trim() ||
asString(rec.error, "").trim() ||
asString(rec.detail, "").trim() ||
asString(rec.code, "").trim();
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
export function parseGrokJsonl(stdout: string): ParsedGrokJsonl {
let sessionId: string | null = null;
let stopReason: string | null = null;
let requestId: string | null = null;
let errorMessage: string | null = null;
const thoughtParts: string[] = [];
const textParts: string[] = [];
const thoughtBoundary = createTurnBoundaryState();
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const type = asString(event.type, "").trim();
if (type === "thought") {
const text = asString(event.data, "");
if (text) thoughtParts.push(applyTurnBoundary(thoughtBoundary, text));
continue;
}
if (type === "text") {
const text = asString(event.data, "");
if (text) textParts.push(text);
continue;
}
if (type === "end") {
sessionId = asString(event.sessionId, "").trim() || sessionId;
stopReason = asString(event.stopReason, "").trim() || stopReason;
requestId = asString(event.requestId, "").trim() || requestId;
continue;
}
if (type === "error") {
const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim();
if (text) errorMessage = text;
}
}
return {
sessionId,
summary: textParts.join("").trim(),
thought: thoughtParts.join("").trim(),
errorMessage,
stopReason,
requestId,
};
}
export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack);
}
@@ -0,0 +1,37 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
async function buildGrokSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
return buildRuntimeMountedSkillSnapshot({
adapterType: "grok_local",
availableEntries,
desiredSkills,
configuredDetail: "Will be copied into `.claude/skills` in the execution workspace on the next run.",
});
}
export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildGrokSkillSnapshot(ctx.config);
}
export async function syncGrokSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildGrokSkillSnapshot(ctx.config);
}
@@ -0,0 +1,142 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const runProcessMock = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
describeAdapterExecutionTarget: () => "local",
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
ensureAdapterExecutionTargetDirectory: ensureDirectoryMock,
resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) =>
configuredCwd || fallbackCwd,
runAdapterExecutionTargetProcess: runProcessMock,
}));
import { parseGrokModelsOutput, testEnvironment } from "./test.js";
describe("parseGrokModelsOutput", () => {
it("extracts auth state and models from `grok models` output", () => {
expect(parseGrokModelsOutput([
"You are logged in with grok.com.",
"",
"Default model: grok-build",
"",
"Available models:",
" * grok-build (default)",
" * grok-code",
].join("\n"))).toEqual({
authenticated: true,
defaultModel: "grok-build",
models: ["grok-build", "grok-code"],
});
});
});
describe("grok_local testEnvironment", () => {
beforeEach(() => {
ensureDirectoryMock.mockClear();
ensureCommandMock.mockClear();
runProcessMock.mockReset();
});
it("reports a healthy authenticated host with a working hello probe", async () => {
runProcessMock
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
"You are logged in with grok.com.",
"",
"Default model: grok-build",
"",
"Available models:",
" * grok-build (default)",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "text", data: "hello" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"),
stderr: "",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: {
command: "grok",
cwd: "/tmp/project",
model: "grok-build",
},
});
expect(result.status).toBe("pass");
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
expect.arrayContaining([
"grok_command_resolvable",
"grok_models_probe_passed",
"grok_model_configured",
"grok_hello_probe_passed",
]),
);
expect(runProcessMock).toHaveBeenNthCalledWith(
2,
expect.any(String),
null,
"grok",
expect.arrayContaining([
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
"--disable-web-search",
"--single",
"Respond with exactly hello.",
]),
expect.any(Object),
);
});
it("downgrades auth failures to warnings", async () => {
runProcessMock
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
})
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: {
command: "grok",
cwd: "/tmp/project",
},
});
expect(result.status).toBe("warn");
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
expect.arrayContaining([
"grok_auth_required",
"grok_hello_probe_auth_required",
]),
);
});
});
@@ -0,0 +1,313 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asNumber,
asString,
asStringArray,
ensurePathInEnv,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
import {
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { parseGrokJsonl } from "./parse.js";
export interface GrokModelsProbe {
authenticated: boolean;
defaultModel: string | null;
models: string[];
}
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
const GROK_AUTH_REQUIRED_RE =
/(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i;
export function parseGrokModelsOutput(stdout: string): GrokModelsProbe {
const trimmedLines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const models: string[] = [];
let defaultModel: string | null = null;
let authenticated = false;
let inModelsBlock = false;
for (const line of trimmedLines) {
if (/logged in/i.test(line)) authenticated = true;
const defaultMatch = /^Default model:\s*(.+)$/i.exec(line);
if (defaultMatch?.[1]) {
defaultModel = defaultMatch[1].trim();
continue;
}
if (/^Available models:/i.test(line)) {
inModelsBlock = true;
continue;
}
if (!inModelsBlock) continue;
const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line);
if (bulletMatch?.[1]) {
models.push(bulletMatch[1].trim());
continue;
}
if (line.length > 0) {
models.push(line.replace(/\s+\(default\)$/, "").trim());
}
}
return {
authenticated,
defaultModel,
models: Array.from(new Set(models.filter(Boolean))),
};
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "grok");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "grok_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "grok_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "grok_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const env = normalizeEnv(config.env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "grok_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "grok_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const canRunProbe =
checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable");
const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
if (canRunProbe) {
const modelsProbe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
["models"],
{
cwd,
env,
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
graceSec: 5,
onLog: async () => {},
},
);
const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`;
const parsedModels = parseGrokModelsOutput(modelsProbe.stdout);
const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput);
if (modelsProbe.timedOut) {
checks.push({
code: "grok_models_probe_timed_out",
level: "warn",
message: "`grok models` timed out.",
hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.",
});
} else if ((modelsProbe.exitCode ?? 1) !== 0) {
checks.push({
code: authRequired ? "grok_auth_required" : "grok_models_probe_failed",
level: authRequired ? "warn" : "error",
message: authRequired
? "Grok CLI is not authenticated."
: "`grok models` failed.",
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
} else {
checks.push({
code: "grok_models_probe_passed",
level: "info",
message: parsedModels.authenticated
? "Grok CLI authentication is configured."
: "`grok models` completed.",
detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined,
});
if (parsedModels.models.length > 0) {
checks.push({
code: "grok_models_discovered",
level: "info",
message: `Discovered ${parsedModels.models.length} Grok model(s).`,
});
} else {
checks.push({
code: "grok_models_empty",
level: "warn",
message: "Grok returned no available models.",
hint: "Run `grok models` manually and verify the account has access to a model.",
});
}
if (configuredModel) {
checks.push({
code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found",
level: parsedModels.models.includes(configuredModel) ? "info" : "warn",
message: parsedModels.models.includes(configuredModel)
? `Configured model: ${configuredModel}`
: `Configured model "${configuredModel}" not found in available models.`,
hint: parsedModels.models.includes(configuredModel)
? undefined
: "Run `grok models` and choose an available model id.",
});
}
}
}
if (canRunProbe) {
const probeArgs = [
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
"--disable-web-search",
];
if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) {
probeArgs.push("--model", configuredModel);
}
probeArgs.push("--single", "Respond with exactly hello.");
const helloProbe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
probeArgs,
{
cwd,
env,
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
graceSec: 5,
onLog: async () => {},
},
);
const parsed = parseGrokJsonl(helloProbe.stdout);
const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage);
const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`);
if (helloProbe.timedOut) {
checks.push({
code: "grok_hello_probe_timed_out",
level: "warn",
message: "Grok hello probe timed out.",
hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.",
});
} else if ((helloProbe.exitCode ?? 1) !== 0) {
checks.push({
code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed",
level: authRequired ? "warn" : "error",
message: authRequired
? "Grok CLI could not answer the hello probe because authentication is missing."
: "Grok hello probe failed.",
...(detail ? { detail } : {}),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
} else if (/\bhello\b/i.test(parsed.summary)) {
checks.push({
code: "grok_hello_probe_passed",
level: "info",
message: "Grok hello probe succeeded.",
});
} else {
checks.push({
code: "grok_hello_probe_unexpected_output",
level: "warn",
message: "Grok hello probe succeeded but returned unexpected output.",
...(detail ? { detail } : {}),
});
}
}
return {
adapterType: "grok_local",
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js";
function run(chunks: string[]): string {
const state = createTurnBoundaryState();
return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join("");
}
describe("applyTurnBoundary", () => {
it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => {
expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
.toBe("The user uses `ls`\nThe `ls` returned");
});
it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => {
expect(run(["returned", ":", "Confirmed", ":", " 4 files"]))
.toBe("returned:\nConfirmed: 4 files");
});
it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => {
expect(run(["render `", "React", "` then "]))
.toBe("render `React` then ");
});
it("leaves natural token streams with proper whitespace alone", () => {
expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"]))
.toBe("The user wants me to:\n1. List");
});
it("does not insert a separator when the next chunk starts with whitespace", () => {
expect(run(["function", ".", " They"]))
.toBe("function. They");
});
it("does not insert a separator when the next chunk starts lowercase", () => {
expect(run(["`", "ls", "`"]))
.toBe("`ls`");
});
it("does not insert a separator when the next chunk is a single character", () => {
expect(run([":", "A"]))
.toBe(":A");
});
it("does not insert a separator after a self-contained backtick span in a single chunk", () => {
// Greptile review: a chunk like "`ls`" is a balanced span; the following
// capitalized word should be treated as a continuation, not a new turn.
expect(run(["`ls`", "Then"]))
.toBe("`ls`Then");
});
});
@@ -0,0 +1,54 @@
// Grok's `--output-format streaming-json` mode emits `thought` and `text` events
// token-by-token. Between reasoning turns (around tool calls) it drops the `\n`
// separator that the non-streaming `--output-format json` mode includes in the
// aggregated `thought` field. This helper inserts a single `\n` when a new chunk
// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``).
export interface TurnBoundaryState {
lastChunk: string;
backtickParity: 0 | 1;
}
export function createTurnBoundaryState(): TurnBoundaryState {
return { lastChunk: "", backtickParity: 0 };
}
function countBackticks(text: string): number {
let count = 0;
for (const ch of text) if (ch === "`") count += 1;
return count;
}
function endsWithSentenceClose(ch: string): boolean {
return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";";
}
export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string {
if (!incoming) return incoming;
let output = incoming;
const prev = state.lastChunk;
if (
prev &&
!/\s$/.test(prev) &&
!/^\s/.test(incoming) &&
/^[A-Z]/.test(incoming) &&
incoming.length >= 2
) {
const lastChar = prev[prev.length - 1]!;
// Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the
// stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a
// self-contained span and the following capitalized word is a continuation,
// not a new turn.
const closingLoneBacktick =
prev === "`" && state.backtickParity === 0;
const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick;
if (looksLikeNewTurn) {
output = `\n${incoming}`;
}
}
state.lastChunk = incoming;
state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1;
return output;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildGrokLocalConfig } from "./build-config.js";
describe("buildGrokLocalConfig", () => {
it("maps create-form values into adapter config", () => {
expect(buildGrokLocalConfig({
cwd: "/tmp/project",
instructionsFilePath: "/tmp/AGENTS.md",
model: "grok-build",
thinkingEffort: "high",
envVars: "XAI_API_KEY=secret\n",
extraArgs: "--check, --verbatim",
} as never)).toEqual({
cwd: "/tmp/project",
instructionsFilePath: "/tmp/AGENTS.md",
model: "grok-build",
timeoutSec: 0,
graceSec: 20,
reasoningEffort: "high",
env: {
XAI_API_KEY: { type: "plain", value: "secret" },
},
extraArgs: ["--check", "--verbatim"],
});
});
});
@@ -0,0 +1,74 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 20;
if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}
@@ -0,0 +1,2 @@
export { parseGrokStdoutLine, createGrokStdoutParser } from "./parse-stdout.js";
export { buildGrokLocalConfig } from "./build-config.js";
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js";
describe("parseGrokStdoutLine", () => {
const ts = "2026-05-15T00:00:00.000Z";
it("maps thought/text/end events into transcript entries", () => {
expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([
{ kind: "thinking", ts, text: "Plan first.", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([
{ kind: "assistant", ts, text: "hello", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
]);
});
it("surfaces structured Grok error payload text", () => {
expect(parseGrokStdoutLine(JSON.stringify({
type: "error",
error: { message: "Authentication required" },
}), ts)).toEqual([
{ kind: "stderr", ts, text: "Authentication required" },
]);
});
});
describe("createGrokStdoutParser", () => {
const ts = "2026-05-15T00:00:00.000Z";
function thoughtTexts(chunks: string[]): string {
const parser = createGrokStdoutParser();
return chunks
.map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts))
.flat()
.map((entry) => entry.kind === "thinking" ? entry.text : "")
.join("");
}
it("inserts a newline between reasoning turns that grok streaming-json glues together", () => {
// Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned"
expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
.toBe("The user uses `ls`\nThe `ls` returned");
});
it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => {
expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"]))
.toBe("returned:\nConfirmed: 4 files");
});
it("resets state between independent transcript builds", () => {
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts);
parser.reset();
expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([
{ kind: "thinking", ts, text: "Second", delta: true },
]);
});
it("does not modify assistant `text` chunks", () => {
// PAPA-349 review feedback: keep final assistant text streaming verbatim;
// the boundary heuristic is scoped to reasoning.
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts);
expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([
{ kind: "assistant", ts, text: "Next", delta: true },
]);
});
});
@@ -0,0 +1,87 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { applyTurnBoundary, createTurnBoundaryState, type TurnBoundaryState } from "../shared/turn-boundary.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractErrorText(value: unknown): string {
if (typeof value === "string") return value;
const record = asRecord(value);
if (!record) return "";
return asString(record.message) || asString(record.detail) || asString(record.code);
}
function parseLineInternal(
line: string,
ts: string,
thoughtBoundary: TurnBoundaryState,
): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type).trim();
if (type === "thought") {
const text = asString(parsed.data);
if (!text) return [];
return [{ kind: "thinking", ts, text: applyTurnBoundary(thoughtBoundary, text), delta: true }];
}
if (type === "text") {
const text = asString(parsed.data);
if (!text) return [];
return [{ kind: "assistant", ts, text, delta: true }];
}
if (type === "error") {
const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error);
return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }];
}
if (type === "end") {
const stopReason = asString(parsed.stopReason).trim();
const sessionId = asString(parsed.sessionId).trim();
const parts = [
stopReason ? `stop_reason=${stopReason}` : "",
sessionId ? `session=${sessionId}` : "",
].filter(Boolean);
return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }];
}
return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }];
}
export function createGrokStdoutParser() {
let thoughtBoundary = createTurnBoundaryState();
return {
parseLine(line: string, ts: string): TranscriptEntry[] {
return parseLineInternal(line, ts, thoughtBoundary);
},
reset() {
thoughtBoundary = createTurnBoundaryState();
},
};
}
// Stateless fallback for callers that haven't migrated to the stateful factory.
// Without state, consecutive thought chunks at reasoning-turn boundaries can
// still appear merged; prefer createGrokStdoutParser for live transcripts.
export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] {
return parseLineInternal(line, ts, createTurnBoundaryState());
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -70,3 +70,16 @@ Structured gateway event logs use:
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates.
## No-remote-git contract
Like every Paperclip adapter, this one must treat the local execution-workspace
cwd as the only persistence boundary across runs — no `git push` from runtime
code, no assuming a `git remote` exists. The gateway transport here doesn't
touch the workspace directly, but if you extend the adapter to ship code to
the OpenClaw side, use the round-trip helpers in `@paperclipai/adapter-utils`
(`prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`)
rather than reaching for a git remote. See
[`packages/adapters/AUTHORING.md`](../AUTHORING.md#no-remote-git-contract-cross-run-persistence)
for the full contract and the pinning test at
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../../adapter-utils/src/ssh-fixture.test.ts).
@@ -9,6 +9,7 @@ import type {
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
asNumber,
asString,
asStringArray,
parseObject,
@@ -72,6 +73,7 @@ export async function testEnvironment(
const command = asString(config.command, "opencode");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -334,6 +336,14 @@ export async function testEnvironment(
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
// Sandbox bridges still add cold-start and transport overhead, but the
// standard-2 Cloudflare tier now probes quickly enough that 90s keeps
// useful headroom without letting slow hangs linger.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 60),
);
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
@@ -343,7 +353,7 @@ export async function testEnvironment(
{
cwd: runtimeCwd,
env: runtimeEnv,
timeoutSec: 60,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
+1 -1
View File
@@ -3,7 +3,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0";
export const models: Array<{ id: string; label: string }> = [];
+101
View File
@@ -309,6 +309,107 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
60_000,
);
it(
"preserves composite foreign key column order without duplicate referenced columns",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_composite_fk_restore_target",
);
const backupDir = createTempDir("paperclip-db-composite-fk-backup-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE SCHEMA "plugin_composite_fk";
CREATE TABLE "plugin_composite_fk"."content_cases" (
"id" uuid PRIMARY KEY,
"company_id" uuid NOT NULL,
"title" text NOT NULL,
CONSTRAINT "content_cases_company_case_unique" UNIQUE ("company_id", "id")
);
CREATE TABLE "plugin_composite_fk"."content_case_signals" (
"company_id" uuid NOT NULL,
"case_id" uuid NOT NULL,
"signal" text NOT NULL,
"scopes" text[] NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
CONSTRAINT "content_case_signals_company_case"
FOREIGN KEY ("company_id", "case_id")
REFERENCES "plugin_composite_fk"."content_cases" ("company_id", "id")
ON DELETE CASCADE
);
INSERT INTO "plugin_composite_fk"."content_cases" ("company_id", "id", "title")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'case'
);
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes", "warnings")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'signal',
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'],
jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"'))
);
`);
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-composite-fk-test",
backupEngine: "javascript",
});
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const rows = await restoreSql.unsafe<{
signal: string;
title: string;
scopes: string[];
warnings: Array<string | { code: string }>;
}[]>(`
SELECT s."signal", c."title", s."scopes", s."warnings"
FROM "plugin_composite_fk"."content_case_signals" s
JOIN "plugin_composite_fk"."content_cases" c
ON c."company_id" = s."company_id"
AND c."id" = s."case_id"
`);
expect(rows).toEqual([
{
signal: "signal",
title: "case",
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"],
warnings: ["json warning", { code: 'quoted "value"' }],
},
]);
await expect(
restoreSql.unsafe(`
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes")
VALUES (
'11111111-1111-4111-8111-111111111111',
'33333333-3333-4333-8333-333333333333',
'orphan',
ARRAY[]::text[]
)
`),
).rejects.toThrow();
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"restores legacy public-only backups without migration history",
async () => {
+82 -54
View File
@@ -249,12 +249,39 @@ function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean {
Object.keys(opts.nullifyColumns ?? {}).length > 0;
}
function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set<string>): string {
function formatPostgresArrayElement(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (Array.isArray(value)) return formatPostgresArrayLiteral(value);
const raw = value instanceof Date
? value.toISOString()
: typeof value === "object"
? JSON.stringify(value)
: String(value);
if (raw.length === 0 || /^null$/i.test(raw) || /[{}\s,"\\]/.test(raw)) {
return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
return raw;
}
function formatPostgresArrayLiteral(value: unknown[]): string {
return `{${value.map(formatPostgresArrayElement).join(",")}}`;
}
function formatSqlValue(
rawValue: unknown,
columnName: string | undefined,
nullifiedColumns: Set<string>,
dataType?: string,
): string {
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (dataType === "json" || dataType === "jsonb") {
return formatSqlLiteral(JSON.stringify(val));
}
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (Array.isArray(val)) return formatSqlLiteral(formatPostgresArrayLiteral(val));
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
}
@@ -745,58 +772,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("");
}
// Foreign keys (after all tables created)
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Unique constraints
// Unique constraints must exist before foreign keys that reference them.
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
@@ -827,6 +803,58 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("");
}
// Foreign keys (after all tables and referenced unique constraints are created)
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Indexes (non-primary, non-unique-constraint)
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
@@ -895,7 +923,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
for await (const rows of rowCursor) {
for (const row of rows) {
const values = row.map((rawValue, index) =>
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns),
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type),
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}

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