diff --git a/.agents/skills/paperclip-page/README.md b/.agents/skills/paperclip-page/README.md new file mode 100644 index 00000000000..4ba859741b4 --- /dev/null +++ b/.agents/skills/paperclip-page/README.md @@ -0,0 +1,621 @@ +# Paperclip Page Skill + +`paperclip-page` publishes static page directories to a Paperclip-controlled S3 +bucket served through CloudFront. It is the durable Paperclip-owned replacement +for quick `here.now`-style page sharing. + +The v1 security posture is: + +- CloudFront + ACM + Origin Access Control in front of a private S3 REST origin. +- Public content only. +- Dedicated uploader IAM identity, separate from Paperclip attachment storage. +- No `s3:DeleteObject`, no `aws s3 sync --delete`, and no bucket/IAM/DNS changes + from the publish helper. +- Symlinks, hidden files, unsafe slugs, and accidental overwrites are rejected. + +## Agent Quick Start + +Build or prepare a static directory with `index.html` at its root: + +```bash +site/ + index.html + assets/app.css + assets/app.js +``` + +Validate without AWS writes: + +```bash +.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --dry-run +``` + +Publish: + +```bash +.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo +``` + +Update an existing page from the same source directory: + +```bash +.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --update +``` + +The helper prints: + +- public URL +- S3 key prefix +- local ownership state path + +## Source Directory Rules + +- `index.html` must exist at the directory root. +- Source directory itself must not be a symlink. +- No symlinks anywhere in the tree. +- No hidden files or dot paths in published content. +- `.paperclip-page/state.json` is allowed and excluded from uploads. +- Do not publish secrets, credentials, internal logs, private company material, + customer data, or regulated data. + +Add this to the publishing repo or generated site `.gitignore` when the source +directory lives in a git checkout: + +```gitignore +.paperclip-page/ +``` + +## Environment Variables + +Required for live publishes: + +```bash +export AWS_REGION=us-east-1 +export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod +export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +``` + +Optional: + +```bash +export PAPERCLIP_PAGE_DEFAULT_PREFIX="" +export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader +``` + +Recommended Paperclip secret names: + +- `paperclip-page-aws-access-key-id` +- `paperclip-page-aws-secret-access-key` + +Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and +`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object +storage credentials. + +## AWS Setup + +Run setup with an operator/admin AWS profile. Agents using this skill should not +create buckets, mutate IAM, change DNS, or manage CloudFront. + +```bash +export AWS_PROFILE=paperclip-admin +export AWS_REGION=us-east-1 +export BUCKET=paperclip-pages-prod +export DOMAIN=pages.paperclip.ing +export UPLOADER_USER=paperclip-page-uploader +export CLOUDFRONT_COMMENT="Paperclip pages" + +aws sts get-caller-identity --profile "$AWS_PROFILE" +``` + +Create the bucket: + +```bash +aws s3api create-bucket \ + --profile "$AWS_PROFILE" \ + --region "$AWS_REGION" \ + --bucket "$BUCKET" +``` + +For regions other than `us-east-1`, add: + +```bash +--create-bucket-configuration LocationConstraint="$AWS_REGION" +``` + +Disable ACLs and keep ownership bucket-enforced: + +```bash +aws s3api put-bucket-ownership-controls \ + --profile "$AWS_PROFILE" \ + --bucket "$BUCKET" \ + --ownership-controls '{"Rules":[{"ObjectOwnership":"BucketOwnerEnforced"}]}' +``` + +Block public access. CloudFront reads through OAC, so the bucket does not need a +public website policy: + +```bash +aws s3api put-public-access-block \ + --profile "$AWS_PROFILE" \ + --bucket "$BUCKET" \ + --public-access-block-configuration \ + 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true' +``` + +Enable versioning: + +```bash +aws s3api put-bucket-versioning \ + --profile "$AWS_PROFILE" \ + --bucket "$BUCKET" \ + --versioning-configuration Status=Enabled +``` + +Enable default encryption: + +```bash +aws s3api put-bucket-encryption \ + --profile "$AWS_PROFILE" \ + --bucket "$BUCKET" \ + --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' +``` + +Upload an operator-managed root `404.html` before creating the distribution: + +```bash +mkdir -p /tmp/paperclip-pages-bootstrap +printf '
` is provided
+- runs `pnpm publish --dry-run --no-git-checks --access public` from the repo root
+- only runs the real `pnpm publish --no-git-checks --access public` when `--publish --otp ` is provided
+
+The helper intentionally uses `pnpm publish` instead of `npm publish` so workspace
+dependencies and `publishConfig` export fields are normalized before the package
+is sent to the registry.
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 6056446b88e..94bd2da67a1 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -37,6 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| 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). Conversational surfaces such as the Conference Room are issue-backed and wake real selected agents by `targetAgentId` (CEO by default). |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
+| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree and resolve only eligible task-level plan confirmations; it is not board authority, active-run output monitoring, or general liveness recovery. |
| 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`) |
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
@@ -152,7 +153,7 @@ Invariant: every business record belongs to exactly one company.
- `status` enum: `active | paused | idle | running | error | pending_approval | terminated`
- `reports_to` uuid fk `agents.id` null
- `capabilities` text null
-- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`
+- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, `hermes_local`, `hermes_gateway`, and `openclaw_gateway`
- `adapter_config` jsonb not null
- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config
- `default_environment_id` uuid fk `environments.id` null
@@ -233,6 +234,10 @@ Routine execution issues add a routine-scoped env overlay after project env and
- identifier fields: `issue_number`, `identifier`
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
- `request_depth` int not null default 0
+- `work_mode` text not null default `standard`; supported values:
+ - `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
+ - `ask`: answer-only execution. Agents may use tools for investigation or temporary scratch work, but the deliverable is an issue-thread answer; they must not write implementation code or produce an implementation plan.
+ - `planning`: plan-only execution. Agents create or revise the plan without implementation work; accepted-plan continuations remain planning-specific and create child issues from the approved plan.
- `billing_code` text null
- `assignee_adapter_overrides` jsonb null
- `execution_policy` jsonb null
@@ -581,6 +586,80 @@ The approved term set is:
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.
+## 9.9 Task Watchdog Authority Contract
+
+A task watchdog is a scoped execution capacity for a configured watchdog agent on one watched issue subtree. It is not a separate principal, does not inherit board auth, and does not expand the selected agent's company boundary. The server must enforce the watchdog contract from persisted watchdog configuration and run context; custom instructions and prompt text can narrow the mandate but cannot expand it.
+
+The watched subtree is the source issue plus descendants reached through `parent_id`, excluding every issue whose `origin_kind = 'task_watchdog'` and excluding all descendants below those watchdog issues. The generated reusable watchdog issue is outside the watched work subtree for scan purposes, but the watchdog agent may update that reusable watchdog issue to record its own review disposition.
+
+Task-watchdog wakes must include server-derived capability metadata that names the watched root, reusable watchdog issue, excluded `task_watchdog` origin branches, allowed operations, and denied operations. Watchdogs must use that metadata and server denials for capability discovery; they must not create visible probe issues, comments, or throwaway tasks to learn their permissions.
+
+### Allowed watchdog mutations
+
+Within the watched subtree, a watchdog run may perform only mutations that restore or clarify the next live/waiting path:
+
+- add comments that explain findings, evidence, and next action
+- create descendant follow-up issues under an included subtree issue, inheriting company, project, goal, and workspace context from that subtree
+- assign or reassign included issues to active, invokable, same-company agents when normal assignment checks and scoped assignment grants allow it
+- move included issues among `todo`, `in_progress`, `in_review`, and `blocked` when the transition is needed to restore a valid action path
+- reopen `done` or `cancelled` included issues only with explicit resume metadata and an audit comment when evidence shows the stopped disposition is wrong or incomplete
+- add, replace, or clear blockers on included issues when the blocker target is in the same company and the change makes the waiting path more accurate
+- set or refresh a one-shot monitor on an included issue when the current assignee owns the future check
+- accept or reject eligible task-level plan confirmations as defined below
+- update the reusable watchdog issue itself to `done`, `in_review`, or `blocked` with the evidence for the watchdog decision
+
+Every watchdog-triggered mutation must write activity with the watchdog id, source issue id, watchdog issue id when present, run id, and stop fingerprint. Mutations still use the normal status-transition, blocker, assignment, budget, and company-boundary guards.
+
+### Disallowed watchdog mutations
+
+A task watchdog must not:
+
+- mutate issues outside the watched subtree, except for comments or newly created follow-up issues that are children of included subtree issues
+- mutate company, project, goal, agent, auth, API key, budget, secret, environment, plugin, or deployment settings
+- approve or reject rows in the `approvals` table, including hiring, CEO strategy, spend, budget override, or `request_board_approval` decisions
+- resolve execution-policy decisions unless the watchdog agent is the typed participant under that policy outside of its watchdog capacity
+- force-release checkout/execution locks, cancel active runs, terminate processes, or perform active-run output watchdog decisions
+- create visible probe issues, comments, or throwaway tasks to discover whether an operation is allowed
+- delete issue documents, comments, attachments, work products, or activity records
+- change the watchdog configuration, select a different watchdog agent, or create nested watchdog configurations
+- treat custom instructions as authority to bypass approval gates, cross company boundaries, access secrets, or override this contract
+
+When the safe next action needs one of these disallowed mutations, the watchdog must leave a valid waiting path by commenting, creating an in-subtree escalation/follow-up issue, assigning to the correct owner, or leaving the source issue blocked on a first-class blocker.
+
+### Interaction resolution
+
+The initial V1 watchdog resolver may resolve exactly one interaction family: `request_confirmation` interactions that are eligible task-level plan confirmations. The watchdog may accept a coherent eligible plan or reject/request changes with a reason. It may not resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, board approvals, or ad hoc document comments.
+
+A plan confirmation is eligible only when all of these are true:
+
+- the interaction is pending and belongs to an issue inside the watched subtree, excluding the reusable watchdog issue and its descendants
+- the interaction target is an `issue_document` with key `plan` on that same issue, and the target revision is still current
+- the interaction has an explicit plan-approval purpose marker; title text, body prose, or idempotency key shape alone is not enough
+- accepting the plan authorizes decomposition or task-level continuation inside the watched subtree only
+- the plan does not request hiring, budget/spend approval, secret access, production deployment, security-sensitive policy changes, legal/compliance decisions, destructive data changes, cross-company work, or any other board-only governed action
+- no newer board/user comment, document revision, superseding interaction, custom instruction, or issue policy reserves the decision for a human, CTO, Security, or the board
+- the plan names concrete child/follow-up work, owners or assignee selection criteria, dependencies/blockers, and acceptance criteria clearly enough that decomposition can proceed without further judgment
+
+If any condition fails, the watchdog must not accept the interaction. It should reject with a reason when the plan is clearly invalid, or leave/escalate the decision when the right owner is a board user, CTO, Security, or another typed approver.
+
+### Downstream acceptance criteria
+
+Implementation, security, UI, and QA work for task watchdogs must prove these contract points:
+
+- server tests deny cross-company watched issues, watchdog agents, watchdog issues, blockers, interactions, and assignment targets
+- server tests deny paused, terminated, pending-approval, budget-blocked, or otherwise uninvokable watchdog agents
+- watchdog-scoped mutations can touch only the watched subtree and the reusable watchdog issue, with activity records for each mutation
+- interaction tests prove only eligible `request_confirmation` plan confirmations are accepted or rejected, and all other interaction kinds remain unavailable to watchdogs
+- plan-confirmation tests cover stale document revisions, missing purpose markers, outside-subtree targets, governed actions, newer user comments, and explicit human/CTO/Security reservations
+- scheduler tests prove live runs, queued wakes, and scheduled retries suppress watchdog wakeups, while terminal, cancelled, blocked, and review leaves are still verified when the subtree has no live path
+- tests prove `task_watchdog` origin issues and descendants are excluded from scans so watchdogs do not trigger themselves
+- regression tests prove watchdog capability discovery comes from wake metadata/denials and denied probes do not create visible issues
+- UI copy and badges distinguish task watchdogs from active-run output watchdogs, monitors, reviewers, approvers, and liveness recovery
+- prompt/context tests prove custom instructions are appended after non-overridable safety constraints and cannot expand authority
+- QA validates a full create/edit/remove/run/reuse flow with screenshots for UI changes
+
+No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: resolving interaction kinds beyond eligible plan confirmations, letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion requires a new product/security review.
+
## 10. API Contract (REST)
All endpoints are under `/api` and return JSON.
@@ -737,6 +816,7 @@ The current app also exposes V1-supporting surfaces for:
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- Conference Room conversation issue resolution through `POST /api/board/chat/conversations` (experimental `enableConferenceRoomChat`; resolves or creates a `board_chat` issue for the selected-agent chat surface)
- execution workspaces, project workspaces, workspace runtime services, and workspace operations
+- task watchdog configuration and reusable watchdog issue orchestration for explicitly watched issue subtrees
- routines and scheduled/API/webhook triggers
- plugin installation, configuration, state, jobs, logs, webhooks, and plugin database namespace migration
- company import/export preview/apply, feedback export/vote routes, instance backup/config routes, invites, join requests, memberships, and permission grants
diff --git a/doc/TASK-WATCHDOG.md b/doc/TASK-WATCHDOG.md
new file mode 100644
index 00000000000..c0775fc4f00
--- /dev/null
+++ b/doc/TASK-WATCHDOG.md
@@ -0,0 +1,205 @@
+# Task Watchdog
+
+## Table of contents
+
+- [Why it exists](#why-it-exists)
+- [Mental model](#mental-model)
+- [Configuration](#configuration)
+ - [From the UI](#from-the-ui)
+ - [From the API](#from-the-api)
+- [How a scan works](#how-a-scan-works)
+- [What the watchdog agent does](#what-the-watchdog-agent-does)
+ - [Writing custom instructions](#writing-custom-instructions)
+- [Scope enforcement](#scope-enforcement)
+- [Origin and badges](#origin-and-badges)
+- [When not to use a watchdog](#when-not-to-use-a-watchdog)
+- [Reference](#reference)
+
+---
+
+A **task watchdog** is an agent you assign to verify a stopped issue tree and put it back into motion when stopping was a mistake. You configure it on a single issue, and it watches that issue plus its non-watchdog descendants. When every leaf in that subtree comes to rest — done, cancelled, blocked, in review, or waiting on an interaction — and there is no live continuation path, Paperclip wakes the watchdog agent to read the evidence and decide whether the stop is legitimate.
+
+Watchdogs are opt-in per issue. There is no global "watch everything" mode.
+
+---
+
+## Why it exists
+
+Agents can stop work for the wrong reasons: misreading a blocker, accepting a stale plan confirmation, declaring "done" without proof, leaving an issue `in_review` with no real reviewer, or running into a recoverable failure and giving up. None of those failures wake anyone on their own — the tree just sits.
+
+A task watchdog gives you a second pass on stopped work without rerunning the original assignee. It is verification-shaped, not execution-shaped: the watchdog reads what other agents claimed, checks it against the evidence in the thread, and either accepts the stop or restores a live path.
+
+It is **not** an output-silence monitor for active runs. That is a separate mechanism — the silent active-run watchdog described in [`doc/execution-semantics.md`](execution-semantics.md) §12. Task watchdog only fires when the whole watched subtree has come to rest.
+
+---
+
+## Mental model
+
+Three concepts share the word "watchdog" inside Paperclip. Keep them separate:
+
+| Concept | What it watches | When it fires |
+| ----------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| **Task watchdog** (this doc) | A configured issue + its non-watchdog descendants | The whole watched subtree has stopped and the stop is new |
+| **Silent active-run watchdog**| A single still-running process | The process has produced no output for the threshold window |
+| **Liveness recovery** | Any agent-owned `in_progress` issue with no live path | Stalled work detected during the periodic recovery scan |
+
+The task watchdog is configured by you (or by an agent on your behalf). The other two run automatically on every project.
+
+---
+
+## Configuration
+
+A watchdog has three fields:
+
+| Field | Required | Notes |
+| ---------------- | -------- | -------------------------------------------------------------------------------------- |
+| Watched issue | yes | The issue you attach the watchdog to. Configured implicitly via the issue you edit. |
+| Watchdog agent | yes | Any same-company, invokable agent. Cannot be paused, terminated, or budget-blocked. |
+| Instructions | no | Free-form text (trimmed; empty becomes null). Can narrow focus; cannot expand authority.|
+
+A single watched issue holds **at most one active watchdog**. Re-assigning the agent or editing instructions invalidates the previously reviewed state and forces a fresh evaluation on the next scan.
+
+### From the UI
+
+Two surfaces edit the watchdog:
+
+- **New issue dialog** — the three-dot menu reveals a **Watchdog** row. Pick an agent and (optionally) type instructions. The chip in the dialog footer shows the chosen agent and a snippet of the instructions. The watchdog is created together with the issue.
+- **Issue properties** — the **Watchdog** row sits next to **Monitor**. Empty state reads `Set watchdog`; configured state shows the watchdog agent icon and name plus a truncated instructions preview. Click the row to open the editor popover with an agent picker, an instructions textarea ("What should the watchdog watch for and how should it keep work moving?"), a **Remove** button, and a **Set watchdog** / **Update** button. When a watchdog run has produced a child review task, the row shows a small badge linking to that task.
+
+### From the API
+
+```http
+GET /api/issues/:issueId/watchdog
+PUT /api/issues/:issueId/watchdog { "agentId": "...", "instructions": "..." | null }
+DELETE /api/issues/:issueId/watchdog
+```
+
+`PUT` is upsert. `DELETE` disables the row (it is not hard-deleted; the table keeps the history for audit). All three routes require write access to the watched issue and produce activity records (`issue.watchdog_created`, `issue.watchdog_updated`, `issue.watchdog_removed`) with the run id and actor.
+
+---
+
+## How a scan works
+
+Paperclip runs a watchdog reconciliation tick at server startup, at the end of each heartbeat cycle, and on demand after any mutation that could change the watched subtree (status, blockers, assignment, interactions). The tick is per-company and only walks active rows.
+
+For each active watchdog the tick:
+
+1. **Walks the watched subtree.** Starts at the configured issue and follows `parent_id` downward, excluding every issue whose `originKind = 'task_watchdog'` and everything below it. This excludes the watchdog's own review tasks so it cannot trigger itself.
+2. **Checks for live paths.** If any included issue has a live run (`queued`, `running`, `scheduled_retry`), a queued wake request, or a scheduled retry, the subtree is **live** and the watchdog does not fire.
+3. **Computes a stop fingerprint.** A SHA-256 hash over the stopped leaves' identifiers, statuses, blockers, pending interactions, and the current watchdog configuration. The configuration is part of the fingerprint, so changing the agent or instructions invalidates the previously reviewed state.
+4. **Compares against `lastReviewedFingerprint`.** Match → suppress (the watchdog already saw this exact stopped state). New → proceed.
+5. **Ensures a review task exists.** Creates (or reopens) one child issue with `originKind = 'task_watchdog'` and `originId = watchedIssueId`. Idempotent per watchdog — only one review task is ever live at a time.
+6. **Wakes the watchdog agent.** Sends a wake with `wakeReason = task_watchdog_stopped_subtree`, the stop fingerprint, the leaf summaries, the default mandate, and any custom instructions. The idempotency key is `(watchdogId, stopFingerprint)`, so retries cannot stack duplicate wakes.
+
+When the subtree changes between scans (someone restarts work, adds a blocker, or accepts an interaction) the stop fingerprint changes too, and the watchdog will be woken again for the new state — even if the previous run already disposed of an earlier fingerprint.
+
+---
+
+## What the watchdog agent does
+
+On wake, the watchdog agent reads a fixed default mandate plus your custom instructions. The mandate explicitly tells it to:
+
+- Treat every stopped leaf as a **claim** that must be verified against comments, documents, work products, screenshots, tests, blockers, and review state. Do not accept "I could not" or "waiting for approval" as automatically valid.
+- Leave genuinely-complete leaves alone, with a short note on what was checked.
+- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or accept an eligible task-level plan confirmation.
+- If the blocker is real, leave a valid waiting disposition that names the unblock owner and the next action.
+
+The mandate also enforces safety constraints that custom instructions **cannot override**:
+
+- Stay inside the watched subtree. No cross-company mutations, no mutations outside the watched issue and its non-watchdog descendants.
+- No impersonating board-only approvals, accepting spend or hiring decisions, accepting security-sensitive interactions, or bypassing execution-policy stages that require a typed reviewer or approver.
+- No creating another watchdog for the watched subtree. No waking itself. Exactly one reusable review task per watched issue.
+- Custom instructions can narrow focus or veto specific shortcuts. They cannot grant authority the server does not already give the watchdog.
+
+The formal authority contract (the full list of allowed and disallowed mutations, and the eligibility test for accepting plan confirmations) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §9.9.
+
+### Writing custom instructions
+
+Custom instructions are most useful when they tell the watchdog what evidence to look at and what shortcuts to refuse. Examples:
+
+> Before accepting any leaf as done, check that there is a corresponding green CI run linked in the comments. If there isn't, reopen the leaf and ask for one.
+
+> Do not accept a `request_confirmation` plan that proposes more than five subtasks without first asking me to review. Leave the issue in review and ping me.
+
+> If a leaf is blocked on the marketing team, accept the wait but make sure the unblock owner is named in the blocker reason.
+
+What custom instructions cannot do: grant authority outside the watched subtree, approve board-level decisions, expand the interaction kinds the watchdog can resolve, or override safety constraints. The server enforces this regardless of what the instructions say.
+
+---
+
+## Scope enforcement
+
+Every watchdog-originated mutation is gated by a server-side scope check derived from the agent run's `contextSnapshot.taskWatchdog` field. The check resolves to a `{ kind: "watchdog", watchdogId, companyId, watchedIssueId, watchdogIssueId }` envelope and rejects:
+
+- mutations on issues outside the watched subtree (parent-chain walk, depth-limited)
+- mutations on issues whose company id does not match the watchdog's company
+- attempts to resolve interactions other than eligible task-level `request_confirmation` plan confirmations (see SPEC §9.9 for eligibility)
+- changes to the watchdog configuration itself (a watchdog cannot edit its own row or create another watchdog)
+- direct edits to active-run output or execution-policy decisions that require a typed participant
+
+The check is wired into the issue update, status change, blocker, assignment, and interaction routes. Any disallowed mutation is rejected at the route layer; the watchdog agent must take a different path (comment, in-subtree follow-up issue, leave a valid waiting state, escalate to a human owner).
+
+---
+
+## Origin and badges
+
+Watchdog-generated review tasks carry `originKind = 'task_watchdog'` and `originId = watchedIssueId`. The UI surfaces this in three places:
+
+- The properties row on the watched issue shows a small **task badge** linking to the active review task whenever one exists.
+- The review task itself carries an origin badge distinguishing it from manually created child issues.
+- The board's audit activity feed labels every watchdog-driven mutation with the watchdog id, source issue id, watchdog issue id, run id, and stop fingerprint.
+
+These origin markers are also what excludes the review task from future scans. The walk-down ignores them, so the watchdog cannot scan or trigger on its own review tasks.
+
+---
+
+## When not to use a watchdog
+
+A task watchdog is useful when:
+
+- the work has many leaves and you want a second pass before trusting "all green"
+- the original assignee tends to declare done too fast, or accept plans you would not accept
+- the tree is important enough that a missed false-stop is worth an extra agent run
+
+It is **not** the right tool for:
+
+- monitoring a single running process for silence — that is the silent active-run watchdog, automatic, no configuration
+- liveness recovery on stalled agent-owned issues without an explicit recovery surface — that is automatic too
+- board-level approvals or anything security-sensitive — the watchdog cannot resolve those
+- replacing a human reviewer on a typed execution-policy stage — the watchdog cannot bypass typed participants
+
+If what you actually want is "wake me when this is done," use a routine or an issue-thread interaction with `continuationPolicy: wake_assignee`, not a watchdog.
+
+---
+
+## Reference
+
+| Topic | File |
+| -------------------------------- | --------------------------------------------------------------------- |
+| Authority contract (formal) | [`doc/SPEC-implementation.md`](SPEC-implementation.md) §9.9 |
+| Execution semantics (formal) | [`doc/execution-semantics.md`](execution-semantics.md) §11 |
+| Silent active-run watchdog | [`doc/execution-semantics.md`](execution-semantics.md) §12 |
+| Database schema | `packages/db/src/schema/issue_watchdogs.ts` |
+| Server service | `server/src/services/task-watchdogs.ts` |
+| Scope enforcement | `server/src/services/task-watchdog-scope.ts` |
+| Wake context + default mandate | `packages/adapter-utils/src/server-utils.ts` (`WATCHDOG_DEFAULT_MANDATE`) |
+| HTTP routes | `server/src/routes/issues.ts` (`GET/PUT/DELETE /issues/:id/watchdog`) |
+| Properties UI | `ui/src/components/IssueProperties.tsx` (Watchdog row) |
+| New-issue dialog UI | `ui/src/components/NewIssueDialog.tsx` |
+
+---
+
+And that's all folks!
+
+```
+ ,.
+ (_|,.
+ ,' /, )_______ _
+ __j o``-' `.'-)'
+ (") \'
+ `-j |
+ `-._( /
+ |_\ |--^. /
+ /_]'|_| /_)_/
+ /_]' /_]'
+```
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index fffdb1dad48..500f9c89bef 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -132,6 +132,14 @@ The active-lock lifecycle is part of the checkout contract:
Stale-lock recovery is crash recovery, not a retry loop. Paperclip must not clear or adopt locks held by non-terminal runs. After stale cleanup, a checkout `409` should mean a real live owner, status/assignee mismatch, unresolved blocker, or active gate still prevents checkout. Agents must treat that `409` as an ownership conflict and stop rather than retrying the same checkout.
+### Pre-dispatch configuration validation
+
+Pre-dispatch configuration validation is a distinct gate that runs after ownership and checkout are resolved but before the control plane actually dispatches a run.
+
+> Before a run is dispatched, required secret/env bindings are validated; missing bindings produce a surfaced configuration-incomplete blocker, not a dispatched run.
+
+A configuration-incomplete result is a gate outcome, not a runtime failure. It is one of the active gates that a checkout-time or dispatch-time check can surface instead of starting a run, and it leaves the issue in an explicit waiting state that names the missing binding. Surfacing the blocker keeps the issue healthy under the liveness contract while preventing a run that is guaranteed to fail once it cannot resolve its required secret/env bindings. A dispatched-then-failed run is the wrong shape for missing configuration: the missing binding is a known pre-dispatch condition, so the control plane must surface it as a configuration-incomplete blocker rather than letting the run start and then fail.
+
## 6. Parent/Sub-Issue vs Blockers
Paperclip uses two different relationships for different jobs.
@@ -218,6 +226,8 @@ The accepted interaction by itself is only evidence that the plan was approved.
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.
+Once decomposition completes and the umbrella's remaining work is "wait for the children to finish," the umbrella must hold a first-class waiting path — a `blocked`-by-children state — not merely `in_progress` resting on `parentId` rollup. `parentId` is not a dependency (§6), so an `in_progress` umbrella with no run, no wake, and no blockers looks stranded to recovery. If the executor instead parks the continuation as waiting-for-review, recovery converts that park into the missing dependency wait (§9.2, "Deliberate wait is not a lost run").
+
### Concurrent and repeat attempts
Every later run that encounters the same accepted-plan fingerprint must consult the durable claim/result before creating children.
@@ -460,6 +470,17 @@ Recovery rule:
This is an active-work continuity recovery.
+#### Deliberate wait is not a lost run
+
+A continuation that the staleness gate cancelled with `issue_continuation_waiting_on_review` is a *deliberate park*, not a disappeared execution path. The latest run reported that the issue is waiting for review/approval (for example, an umbrella issue whose work was just decomposed into sub-tasks). Treating that park as a stranded run would retry it, then escalate it to `blocked` with a recovery action and an operator-facing failure notice — even though nothing failed and there is nothing for a human to do.
+
+Recovery rule for a parked-for-review continuation:
+
+- if the issue has a real waiting target — open (non-terminal) sub-tasks or existing unresolved blockers — Paperclip converts the deliberate wait into a first-class dependency wait: it sets the issue `blocked` by those issues, keeps the original assignee, and posts a plain-language comment explaining that the task will resume automatically when its dependencies finish. The issue then self-resumes through the normal `issue_blockers_resolved` path; no recovery action or escalation owner is involved
+- if the issue has no waiting target, the park is indistinguishable from a genuine strand and falls through to the standard §9.2 escalation, preserving stranded detection
+
+This keeps the post-decomposition umbrella (§7) on a real waiting path instead of relying on `parentId` rollup, which §6 does not treat as a dependency.
+
### 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`.
@@ -480,7 +501,87 @@ On startup and on the periodic recovery loop, Paperclip now does five things in
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.
-## 11. Silent Active-Run Watchdog
+## 11. Task Watchdog for Issue Trees
+
+A task watchdog watches a configured issue subtree after that subtree has stopped moving. It is a product-level verification and recovery mechanism for selected work, not a process monitor.
+
+Keep the three watchdog/recovery concepts separate:
+
+- task watchdog: watches a configured source issue plus non-watchdog descendants and asks whether the stopped subtree is legitimate
+- silent active-run watchdog: watches a still-running process that has stopped producing output
+- liveness recovery: repairs stranded control-plane paths when a non-terminal issue has no live, waiting, or recovery path
+
+### Configuration and scan scope
+
+A source issue may have at most one active task watchdog configuration. The configuration names a same-company, invokable watchdog agent and optional custom instructions.
+
+The scan scope is:
+
+- the source issue
+- descendants reached through `parentId`
+- excluding every issue whose `originKind` is `task_watchdog`
+- excluding every descendant below an excluded task-watchdog issue
+
+The reusable watchdog issue is a child of the watched source issue for audit and navigation, but it is excluded from the watched work subtree. This prevents recursive watchdog loops.
+
+### Stopped-subtree evaluation
+
+Task watchdog evaluation is conservative. If any included issue has a live run, queued wake, or scheduled retry that should fire without intervention, the subtree is live and the task watchdog does not run.
+
+If no included issue has a live path, Paperclip computes a stop fingerprint from durable subtree state, including at least:
+
+- included leaf issue ids, statuses, assignees, and latest durable update timestamps
+- first-class blockers and unresolved blocker leaf summaries
+- pending interactions and approvals that define waiting paths
+- active monitors and scheduled retries
+- terminal or cancelled leaf evidence
+- the watchdog configuration revision, including watchdog agent and instructions changes
+
+If the fingerprint equals the watchdog's last reviewed fingerprint, Paperclip suppresses another watchdog wake. If the fingerprint is new, Paperclip creates or reopens the reusable watchdog issue and wakes the configured watchdog agent with the source issue, watchdog config, stop fingerprint, leaf summary, default mandate, custom instructions, and server-derived capability metadata that names the allowed operations, denied operations, reusable watchdog issue, and non-watchdog target scope.
+
+Changing the watchdog agent or custom instructions invalidates the reviewed fingerprint and forces a fresh evaluation even if the subtree state did not otherwise change.
+
+### Live path created by watchdog work
+
+An active watchdog issue or queued watchdog wake can be the visible recovery path for a stopped watched subtree, but it is not proof that the original deliverable work is complete. It means the next action is watchdog verification.
+
+When the source issue is non-terminal and has no other live path, the product should expose the watchdog issue or source-scoped recovery action as the reason the subtree is covered. When correctness requires the source issue to wait on watchdog review, the source issue should be blocked on the reusable watchdog issue or an equivalent explicit recovery action. Do not rely on parent/child structure alone.
+
+### Watchdog authority during execution
+
+The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or beyond the allowed mutation and interaction list.
+
+Watchdogs must not create visible probe issues, comments, or throwaway tasks to discover capability boundaries. They should rely on the wake capability metadata and explicit API denials, then record any denied operation as evidence in the reusable watchdog issue.
+
+The watchdog should verify stopped leaves against comments, documents, work products, tests, screenshots, blockers, review state, and run context. It should not accept "I could not" or "waiting for approval" as sufficient by itself.
+
+When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an eligible plan confirmation. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
+
+### Eligible interaction decisions
+
+A task watchdog may resolve only eligible `request_confirmation` plan confirmations. Eligibility is defined in `doc/SPEC-implementation.md` and must be checked by the server at decision time. The critical constraints are:
+
+- the interaction is pending, targeted at the current `plan` document revision for an included subtree issue, and explicitly marked as a plan-approval confirmation
+- accepting it authorizes only decomposition or task-level continuation inside the watched subtree
+- the plan is not asking for board-only governance, spend, hiring, security, deployment, secret, destructive data, legal/compliance, cross-company, or other sensitive approval
+- no newer durable source activity or policy reserves the decision for a human, CTO, Security, or the board
+
+The watchdog cannot resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, execution-policy decisions unless it is the typed participant outside watchdog capacity, or document comments written as freeform approval.
+
+### Completion and fingerprint updates
+
+The watchdog's reviewed fingerprint should update only after the watchdog issue reaches a valid disposition:
+
+- `done` with evidence that the stopped state is acceptable
+- `in_review` with a real reviewer, approval, interaction, user owner, monitor, or recovery path
+- `blocked` with first-class blockers or a named external owner/action
+- a watchdog mutation that restores live work, where the subsequent source-subtree mutation naturally changes the stop fingerprint
+
+If the watchdog moved work forward, Paperclip should not mark the old fingerprint as permanently acceptable just because the watchdog issue completed. The next scan should observe the changed subtree state and either suppress because work is live or compute a new stopped fingerprint later.
+
+Task watchdogs must not silently mark source work done from prose comments, must not duplicate child trees for the same accepted plan revision, and must not create another task-watchdog issue for the same source issue.
+
+## 12. 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.
@@ -532,7 +633,7 @@ This is distinct from productivity review. Productivity review asks whether an a
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
+## 13. Auto-Recover vs Explicit Recovery vs Human Escalation
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
@@ -576,7 +677,7 @@ Examples:
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
-## 13. What This Does Not Mean
+## 14. What This Does Not Mean
These semantics do not change V1 into an auto-reassignment system.
@@ -593,7 +694,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
-## 14. Practical Interpretation
+## 15. Practical Interpretation
For a board operator, the intended meaning is:
diff --git a/doc/plans/2026-04-24-external-object-reference-backfill.md b/doc/plans/2026-04-24-external-object-reference-backfill.md
new file mode 100644
index 00000000000..02b8e86730c
--- /dev/null
+++ b/doc/plans/2026-04-24-external-object-reference-backfill.md
@@ -0,0 +1,28 @@
+# External Object Reference Backfill
+
+## Purpose
+
+Backfill existing issue titles, descriptions, comments, and documents into `external_object_mentions` after the Phase 3 detector registry and mention sync service exist.
+
+## Command Shape
+
+Add a command parallel to the internal issue-reference backfill:
+
+```sh
+pnpm external-objects:backfill
+pnpm external-objects:backfill -- --company
+pnpm external-objects:backfill -- --dry-run
+```
+
+## Required Behavior
+
+- Scan issue title and description sources, issue comments, and issue documents through the same external-object mention sync service used by write hooks.
+- Preserve company boundaries: every scan query, mention upsert, object upsert, and dry-run summary must include `company_id`.
+- Use shared URL extraction and canonicalization helpers so raw URLs, URL userinfo, query strings, and fragments are never persisted.
+- Replace mentions per source instead of appending, using the same source key shape as live write sync.
+- Report counts by company, source kind, provider key, object type, skipped userinfo URLs, and unresolved URLs.
+- Default to a non-interactive safe run; `--dry-run` must avoid writes while still reporting what would change.
+
+## Phase 3 Hook
+
+The command should be implemented only after the detector registry can classify URLs and the service can upsert mentions/placeholders consistently. Until then, Phase 2 owns the schema and shared helpers that make the command safe.
diff --git a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md
index 3bf7b009455..124ba3c0acd 100644
--- a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md
+++ b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md
@@ -46,6 +46,14 @@ pnpm paperclipai run
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
+> **Verifying branch behavior?** If you are testing a plugin against routes or
+> data shapes that only exist on a feature branch, the server you install into
+> must be the one running that branch's code. A long-lived control-plane host
+> may be on older code and silently return `API route not found` for routes the
+> branch added, which makes the plugin look broken when the real problem is the
+> test target. See [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime)
+> before you install.
+
### 2. Scaffold the plugin
```bash
@@ -83,9 +91,11 @@ paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
-You will see a confirmation like:
+Before it installs, the CLI probes `GET /api/health` on the instance it is configured to talk to and prints the **target diagnostics** so you can confirm *which* Paperclip you are installing into. You will see a confirmation like:
```
+Target Paperclip: http://127.0.0.1:3100
+ health: status=ok version=0.1.0 mode=local_trusted exposure=private
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
✓ Installed acme.hello-plugin v0.1.0 (ready)
Local plugin installs run trusted local code from your machine.
@@ -93,6 +103,8 @@ Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
Paperclip watches rebuilt dist output and reloads the plugin worker.
```
+Read that first line. If the API URL, version, or mode is not the instance you expect, stop and re-point the CLI (see [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime)) before trusting the result. Pass `--no-verify-target` to skip the probe, or run `paperclipai plugin target` to see the same diagnostics without installing anything.
+
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
### 5. Inspect
@@ -104,6 +116,67 @@ paperclipai plugin inspect acme.hello-plugin
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
+## Targeting a branch / issue-workspace runtime
+
+The five-step loop above assumes one Paperclip on `http://127.0.0.1:3100`. That breaks down the moment your plugin depends on **server code that only exists on a branch**. Examples:
+
+- a new scoped API route the plugin calls (e.g. a `GET /api/companies/:companyId/...` endpoint the branch adds),
+- a new field in an existing response the plugin reads,
+- a new managed-resource capability the worker reconciles.
+
+If you install the plugin into a long-lived control-plane host that is still on older code, the route or field is missing there. The plugin falls back or errors, and it *looks* like a plugin bug when the real problem is that you tested against the wrong runtime. To verify "what the published plugin will actually do," install into a Paperclip service that is **serving your branch**.
+
+### How the CLI chooses its target
+
+The CLI resolves the API base URL in this order (highest priority first):
+
+1. `--api-base ` flag on the command,
+2. `PAPERCLIP_API_URL` environment variable,
+3. the active CLI context profile's `apiBase`,
+4. inferred default `http://:`.
+
+So the API URL is explicit and overridable — the gap was never that you *couldn't* point at a branch server, it was that nothing told you which server you ended up on. `paperclipai plugin target` and the pre-install probe close that gap.
+
+### Run the branch service and install into it
+
+```bash
+# 1. From the branch checkout (e.g. an issue worktree), run that branch's server.
+# Pick a port that does not collide with any control-plane instance.
+PAPERCLIP_SERVER_PORT=3120 pnpm dev # or: pnpm paperclipai run
+
+# 2. Confirm the CLI will talk to that exact branch service before installing.
+paperclipai plugin target --api-base http://127.0.0.1:3120
+# Target Paperclip: http://127.0.0.1:3120
+# health: status=ok version= mode=local_trusted exposure=private
+
+# 3. Install the local-path plugin into that service (not the default host).
+paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin \
+ --api-base http://127.0.0.1:3120
+
+# Prefer setting it once for the shell instead of repeating --api-base:
+export PAPERCLIP_API_URL=http://127.0.0.1:3120
+paperclipai plugin target
+paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
+```
+
+`plugin target` and the install-time probe both read `GET /api/health`, which returns the server `version`, `deploymentMode`, and `deploymentExposure`. Compare that `version` against the branch you expect to be running. If the diagnostics show a different URL, an unexpected version, or `health: unreachable`, you are about to test against the wrong instance — fix the target before reading anything into the plugin's behavior.
+
+### End-to-end check that the branch route is actually served
+
+When the behavior you care about is a branch-only route, hit it directly against the same target you installed into, so you prove the route exists there rather than inferring it from plugin output:
+
+```bash
+# Same base URL you installed into; expect JSON, not "API route not found".
+curl -s "http://127.0.0.1:3120/api/companies//" \
+ -H "Authorization: Bearer $PAPERCLIP_API_KEY" | head
+```
+
+If that returns the route's JSON, the branch runtime is serving the route and the plugin is exercising real published behavior. If it returns `API route not found`, the service on that port is not running your branch code — restart the branch server (step 1) and re-check `plugin target` before continuing.
+
+### Why not just patch the control-plane host?
+
+You can, but you usually should not. The control-plane host is shared and may be deliberately pinned to a released version. Spinning up the branch service on its own port and pointing the CLI at it keeps your in-progress plugin work isolated, reproducible, and honest about which code it ran against. When you are done, publish the plugin as an npm package and install that form against the host you will actually ship on.
+
## Reload semantics, honestly
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
@@ -116,7 +189,7 @@ What that means in practice:
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
-The server never compiles plugin source for you. The package's own build scripts own that step.
+The package's own build scripts still own compilation. Paperclip does not compile arbitrary local-path plugins for you. The exceptions are bundled plugins inside the Paperclip repo under `packages/plugins/`: workspace packages auto-build once with `pnpm --filter build`, and standalone sandbox-provider packages under `packages/plugins/sandbox-providers/` first bootstrap package-local dependencies with `pnpm install --ignore-workspace ...` and then run `pnpm build` in place. Set `PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD=1` in the server environment to disable those fallbacks.
## Local path plugins vs npm packages
@@ -136,7 +209,8 @@ When you are done iterating locally, publish the package and reinstall the npm-p
## Troubleshooting
-- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect ` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
+- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect ` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. Bundled repo plugins may auto-build once during install, but external local-path plugins still require you to build them yourself. The Paperclip server log has the full validation error.
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
+- **Plugin behaves as if a route or field is missing (e.g. `API route not found`, empty data, or a fallback path triggering unexpectedly).** You are probably installed into a Paperclip instance that does not run your branch code. Run `paperclipai plugin target` and compare the reported API URL and `version` against the branch service you meant to test. See [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime) to run the branch server and point the CLI at it explicitly.
diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
index 5f8380f7b19..b541ff5caa4 100644
--- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
+++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
@@ -12,6 +12,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
+- External object reference providers are trusted-install only in the MVP.
+ Capabilities gate provider detection/resolution and host API calls, but they
+ are not a sandbox boundary for untrusted marketplace code.
- 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.
@@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
building custom versions.
- `ctx.assets` is not supported in the current runtime.
+## External object reference providers
+
+Plugins can contribute provider-neutral object reference detection and status
+resolution for URLs and future explicit links. Declare `objectReferences` in the
+manifest and add at least `external.objects.detect` and `external.objects.read`.
+
+```ts
+objectReferences: [
+ {
+ providerKey: "mocktracker",
+ displayName: "Mock Tracker",
+ objectTypes: ["ticket"],
+ urlPatterns: ["https://mock.example/tickets/:id"],
+ },
+],
+```
+
+Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL
+candidates and return provider-stable identities. Implement
+`onResolveExternalObject()` to return normalized board-safe status metadata.
+Paperclip owns inline markdown rendering; plugins must not return React, HTML,
+or `dangerouslySetInnerHTML` content for inline references.
+
## Scaffold a plugin
Use the CLI scaffold command:
diff --git a/docker/hermes-gateway-smoke/Dockerfile b/docker/hermes-gateway-smoke/Dockerfile
new file mode 100644
index 00000000000..5939c7df6ea
--- /dev/null
+++ b/docker/hermes-gateway-smoke/Dockerfile
@@ -0,0 +1,52 @@
+# syntax=docker/dockerfile:1.6
+ARG HERMES_VERSION=0.17.0
+FROM node:22-bookworm-slim
+
+ARG HERMES_VERSION
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ bash \
+ ca-certificates \
+ curl \
+ git \
+ jq \
+ python3 \
+ python3-pip \
+ tini \
+ && rm -rf /var/lib/apt/lists/*
+
+# The npm package is a pinned bridge that exposes the `hermes` binary. Install
+# the matching Python package globally first so the non-root runtime user can
+# import the upstream Hermes modules at container start.
+RUN python3 -m pip install --break-system-packages --no-cache-dir "hermes-agent[anthropic]==${HERMES_VERSION}" "aiohttp==3.13.4" \
+ && rm -f /usr/local/bin/hermes /usr/local/bin/hermes-agent \
+ && npm install -g --ignore-scripts "hermes-agent@${HERMES_VERSION}" \
+ && python3 -c "import hermes_cli.main" \
+ && command -v hermes >/dev/null
+
+RUN useradd --create-home --shell /bin/bash --uid 10001 hermes \
+ && mkdir -p /home/hermes/.hermes /home/hermes/workspace \
+ && chown -R hermes:hermes /home/hermes
+
+COPY entrypoint.sh /usr/local/bin/hermes-gateway-entrypoint
+RUN chmod 0755 /usr/local/bin/hermes-gateway-entrypoint
+
+USER hermes
+WORKDIR /home/hermes/workspace
+
+ENV HOME=/home/hermes \
+ HERMES_HOME=/home/hermes/.hermes \
+ XDG_CONFIG_HOME=/home/hermes/.config \
+ XDG_CACHE_HOME=/home/hermes/.cache \
+ XDG_DATA_HOME=/home/hermes/.local/share \
+ API_SERVER_ENABLED=true \
+ API_SERVER_HOST=0.0.0.0 \
+ API_SERVER_PORT=8642 \
+ NO_COLOR=1
+
+EXPOSE 8642
+
+ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/hermes-gateway-entrypoint"]
diff --git a/docker/hermes-gateway-smoke/entrypoint.sh b/docker/hermes-gateway-smoke/entrypoint.sh
new file mode 100755
index 00000000000..a1496afa8c0
--- /dev/null
+++ b/docker/hermes-gateway-smoke/entrypoint.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+log() {
+ echo "[hermes-gateway-smoke] $*"
+}
+
+hash_prefix() {
+ local value="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ printf "%s" "$value" | sha256sum | awk '{print substr($1,1,12)}'
+ elif command -v shasum >/dev/null 2>&1; then
+ printf "%s" "$value" | shasum -a 256 | awk '{print substr($1,1,12)}'
+ else
+ printf "unavailable"
+ fi
+}
+
+generate_key() {
+ node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'
+}
+
+export HOME="${HOME:-/home/hermes}"
+export HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
+export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME}/.config}"
+export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}"
+export XDG_DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
+export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
+export API_SERVER_HOST="${API_SERVER_HOST:-0.0.0.0}"
+export API_SERVER_PORT="${API_SERVER_PORT:-8642}"
+export NO_COLOR="${NO_COLOR:-1}"
+
+if [[ "${API_SERVER_ENABLED}" != "true" ]]; then
+ log "forcing API_SERVER_ENABLED=true for gateway smoke"
+ export API_SERVER_ENABLED=true
+fi
+
+if [[ -z "${API_SERVER_KEY:-}" ]]; then
+ API_SERVER_KEY="$(generate_key)"
+ export API_SERVER_KEY
+ log "generated API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}"
+else
+ log "using provided API_SERVER_KEY sha256=$(hash_prefix "$API_SERVER_KEY") len=${#API_SERVER_KEY}"
+fi
+
+mkdir -p "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$HOME/workspace"
+chmod 0700 "$HERMES_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" || true
+
+log "HOME=${HOME}"
+log "HERMES_HOME=${HERMES_HOME}"
+log "workspace=$(pwd)"
+log "API_SERVER_HOST=${API_SERVER_HOST}"
+log "API_SERVER_PORT=${API_SERVER_PORT}"
+log "state listing hash=$(find "$HERMES_HOME" -maxdepth 2 -type f -print 2>/dev/null | sort | sha256sum | awk '{print substr($1,1,12)}')"
+
+exec hermes gateway run --replace --accept-hooks "$@"
diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md
index 927e7cf3b0f..fa4877b05a7 100644
--- a/docs/adapters/codex-local.md
+++ b/docs/adapters/codex-local.md
@@ -45,6 +45,16 @@ Paperclip currently applies that only when the selected model is `gpt-5.4`. On o
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
+### Per-agent isolation and auth seeding
+
+For `codex_local` agents the server isolation guard pins each agent to a per-agent home (`/companies//agents//codex-home`) and sets `OPENAI_API_KEY=""` so an agent can never spend against the host API key or share another agent's Codex state.
+
+A managed home is created empty, so the adapter must provision auth into it before launching Codex — otherwise the agent runs with zero credentials and the provider returns `401 Missing bearer`. The seeding contract:
+
+- **Managed homes** (the default home and any configured `CODEX_HOME` under the company tree) are always seeded: the ChatGPT-subscription `auth.json` is symlinked from the host Codex home, or, when a per-agent `OPENAI_API_KEY` is configured, an API-key `auth.json` is written instead.
+- **Genuine external overrides** (a `CODEX_HOME` outside the Paperclip-managed company tree) are treated as self-managed and are never seeded or overwritten.
+- **Fail-fast guard:** if a managed home ends up with no usable `auth.json` and no configured API key, the run fails with an explicit `adapter_failed` ("no Codex credentials provisioned for managed home …") rather than emitting an unauthenticated request.
+
## Manual Local CLI
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md
index 3c814fc9abb..522679f667c 100644
--- a/docs/adapters/external-adapters.md
+++ b/docs/adapters/external-adapters.md
@@ -15,6 +15,22 @@ Paperclip supports external adapter plugins that can be installed from npm packa
| Distribution | Ships with Paperclip | Published to npm or linked via `file:` |
| Updates | Requires Paperclip release | Independent versioning |
+### Built-in Hermes compatibility note
+
+Hermes is built in with two stable adapter type keys:
+
+- `hermes_local` starts the local Hermes CLI from
+ `@paperclipai/hermes-paperclip-adapter`.
+- `hermes_gateway` calls an already-running Hermes API server through
+ `@paperclipai/hermes-paperclip-adapter/gateway`.
+
+The legacy `@paperclipai/adapter-hermes-gateway` package is a deprecated
+compatibility shim for one release. It preserves the old gateway exports while
+forwarding to the unified Hermes package. New external override packages should
+depend on or link `@paperclipai/hermes-paperclip-adapter` and declare the type
+they override (`hermes_local` or `hermes_gateway`); the type keys did not
+change.
+
## Quick Start
### Minimal Package Structure
diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md
index dfb4b21fb3a..a2f62b54bba 100644
--- a/docs/adapters/overview.md
+++ b/docs/adapters/overview.md
@@ -24,11 +24,25 @@ When a heartbeat fires, Paperclip:
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| Cursor | `cursor` | Runs Cursor in background mode |
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
-| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) |
+| Hermes Local | `hermes_local` | Runs the local Hermes CLI through `@paperclipai/hermes-paperclip-adapter` |
+| Hermes Gateway | `hermes_gateway` | Calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway` |
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
+### Hermes local vs gateway
+
+Use `hermes_local` when Paperclip should start the local `hermes` CLI on the
+same host for each heartbeat. Use `hermes_gateway` when Hermes is already
+running as an HTTP/SSE API server and Paperclip should call that server instead
+of spawning a process. Both type keys are stable built-ins.
+
+The unified Hermes package owns both built-in adapters. The older
+`@paperclipai/adapter-hermes-gateway` package remains only as a deprecated
+compatibility shim that re-exports the gateway entrypoints for one release.
+New plugin overrides should target `@paperclipai/hermes-paperclip-adapter` and
+set the desired type key (`hermes_local` or `hermes_gateway`).
+
### External (plugin) adapters
These adapters ship as standalone npm packages and are installed via the plugin system:
@@ -79,8 +93,9 @@ my-adapter/
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
+- **Need Hermes on another host or already running as a service?** Use `hermes_gateway`
- **Need to run a script or command?** Use `process`
-- **Need to call an external service?** Use `http`
+- **Need to call a custom external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters)
## UI Parser Contract
diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md
index 81bbda7ca15..252fa3d0a2d 100644
--- a/docs/agents-runtime.md
+++ b/docs/agents-runtime.md
@@ -39,7 +39,8 @@ Built-in adapters:
- `opencode_local`: runs your local `opencode` CLI
- `cursor`: runs Cursor in background mode
- `pi_local`: runs an embedded Pi agent locally
-- `hermes_local`: runs your local `hermes` CLI (`hermes-paperclip-adapter`)
+- `hermes_local`: starts your local `hermes` CLI through `@paperclipai/hermes-paperclip-adapter`
+- `hermes_gateway`: calls an already-running Hermes API server through `@paperclipai/hermes-paperclip-adapter/gateway`
- `openclaw_gateway`: connects to an OpenClaw gateway endpoint
- `process`: generic shell command adapter
- `http`: calls an external HTTP endpoint
@@ -48,7 +49,7 @@ External plugin adapters (install via the adapter manager or API):
- `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`)
-For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
+For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. For `hermes_gateway`, Paperclip assumes the Hermes API server is already running, reachable from the Paperclip server, and configured with an API key. The older `@paperclipai/adapter-hermes-gateway` npm package is only a deprecated compatibility shim; the adapter type remains `hermes_gateway`.
## 3.2 Runtime behavior
@@ -177,7 +178,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r
## 10. Minimal setup checklist
-1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager.
+1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `hermes_gateway`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager.
2. Set `cwd` to the target workspace (for local adapters).
3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle.
4. Configure heartbeat policy (timer and/or assignment wakeups).
diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md
index 29e1101d011..406b3190bd7 100644
--- a/docs/cli/control-plane-commands.md
+++ b/docs/cli/control-plane-commands.md
@@ -134,6 +134,17 @@ pnpm paperclipai activity list [--agent-id ] [--entity-type issue] [--entity
pnpm paperclipai dashboard get
```
+## Instance Settings
+
+```sh
+pnpm paperclipai instance settings:general
+pnpm paperclipai instance settings:general:update --payload-json '{...}'
+pnpm paperclipai instance settings:experimental
+pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
+```
+
+Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
+
## Heartbeat
```sh
diff --git a/docs/docs.json b/docs/docs.json
index 54dfb003eb6..5787bda0559 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -50,6 +50,7 @@
"guides/board-operator/delegation",
"guides/board-operator/execution-workspaces-and-runtime-services",
"guides/board-operator/delegation",
+ "guides/board-operator/experimental-features",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log",
@@ -62,6 +63,7 @@
"guides/agent-developer/how-agents-work",
"guides/agent-developer/heartbeat-protocol",
"guides/agent-developer/writing-a-skill",
+ "guides/agent-developer/skills-store",
"guides/agent-developer/task-workflow",
"guides/agent-developer/comments-and-communication",
"guides/agent-developer/handling-approvals",
diff --git a/docs/guides/agent-developer/skills-store.md b/docs/guides/agent-developer/skills-store.md
new file mode 100644
index 00000000000..718d5dc415e
--- /dev/null
+++ b/docs/guides/agent-developer/skills-store.md
@@ -0,0 +1,277 @@
+---
+title: The Skills Store
+summary: Browse, install, import, fork, and share the reusable skills your agents use
+---
+
+The **Skills Store** is Paperclip's library of reusable skills. A skill is a markdown
+playbook that teaches an agent how to do a specific kind of work — triage an issue,
+write a wireframe, run QA acceptance, draft a release announcement. The Store is where
+people (and agents) discover those skills, install them into a company, and manage them
+over time.
+
+If you want to *author* a skill, read [Writing a Skill](writing-a-skill). This page is
+about the **store around** skills: where they come from, how they get into your company,
+and how you keep them current.
+
+## Two layers: the catalog and your company library
+
+There are two distinct things people loosely call "the skills store":
+
+| Layer | What it is | Lives in |
+|---|---|---|
+| **The catalog** | A curated, read-only set of skills that ships with Paperclip | The `@paperclipai/skills-catalog` package |
+| **Your company library** | The skills actually installed in *your* company, which agents can run | The `company_skills` database table |
+
+The catalog is the shelf you browse. Your company library is the cart you've checked
+out. Installing a catalog skill copies it into your company library, where you can edit,
+version, fork, and share it independently of the original.
+
+### The bundled catalog
+
+The catalog is built from markdown under `packages/skills-catalog/catalog/` and compiled
+into a manifest (`generated/catalog.json`) at build time. Each catalog skill is one
+directory containing a `SKILL.md` plus any supporting `references/`, `scripts/`, or
+`assets/` files.
+
+The catalog splits skills into two **kinds**:
+
+- **`bundled`** — first-party Paperclip skills (e.g. `issue-triage`, `task-planning`,
+ `qa-acceptance`, `wireframe`, `github-pr-workflow`, `doc-maintenance`). These carry the
+ reserved `paperclipai/paperclip/...` key namespace.
+- **`optional`** — additional curated skills you opt into (e.g. `agent-browser`,
+ `design-critique`, `release-announcement`, `last30days`).
+
+Every catalog skill carries metadata used for discovery and safety:
+
+- **`category`** — grouping such as `software-development`, `quality`, `product`,
+ `research`, `content`, `browser`, `paperclip-operations`, `docs`.
+- **`recommendedForRoles`** — agent roles the skill suits (`engineer`, `qa`, `designer`,
+ `product`, `researcher`, …), used to suggest skills when staffing a company.
+- **`trustLevel`** — see [Trust levels](#trust-levels-what-a-skill-is-allowed-to-carry).
+- **`compatibility`** — `compatible`, `unknown`, or `invalid`, derived during the build
+ validation pass.
+- **`contentHash`** — a hash of the skill's files, used later to detect updates and drift.
+
+## Trust levels: what a skill is allowed to carry
+
+Because a skill can bundle more than prose, every skill is classified by how much trust
+its contents require. The level is **derived from the files**, not self-declared:
+
+| Trust level | Contains | Notes |
+|---|---|---|
+| `markdown_only` | Only `.md` files | Safest — pure instructions |
+| `assets` | Markdown plus images/PDFs/other static files | No executable code |
+| `scripts_executables` | Any script (`.sh`, `.js`, `.py`, `.ts`, …) | Highest scrutiny |
+
+Trust level gates what can be imported. A skill that carries executable scripts **cannot
+be imported from an external source** (GitHub, `skills.sh`, or a raw URL) — only
+first-party bundled catalog skills are allowed to ship scripts. This keeps untrusted
+remote code out of your agents' hands.
+
+## Where skills come from (source types)
+
+A skill in your company library records where it originated. The Store shows this as a
+**source badge**:
+
+| Source type | Badge | Meaning |
+|---|---|---|
+| `catalog` | Paperclip / catalog | Installed from the bundled catalog |
+| `github` | GitHub | Imported from a GitHub repo (pinned to a commit) |
+| `skills_sh` | skills.sh | Imported via the [skills.sh](https://skills.sh) registry (resolves to GitHub) |
+| `url` | URL | Imported from a raw markdown URL |
+| `local_path` | Local | Created in-app or scanned from a project workspace on disk |
+
+External imports (`github`, `skills_sh`, `url`) are held to two rules: they must be
+`markdown_only` or `assets` (no scripts), and Git-backed sources **must resolve to a
+pinned 40-character commit SHA** before import, so a moving branch can never silently
+change what your agents run.
+
+## Getting skills into your company
+
+The Store offers several paths, all of which land a skill in your company library.
+
+### Install from the catalog
+
+Browse the catalog's discovery grid, pick a skill, and install it. Installing copies the
+catalog skill's files into your company library and stamps provenance metadata (the
+catalog key, content hash, and package version) so the Store can later tell you when the
+upstream catalog skill has changed.
+
+- API: `POST /companies/:companyId/skills/install-catalog`
+- Re-installing an already-installed catalog skill updates it in place rather than
+ creating a duplicate.
+
+### Import from an external source
+
+Paste a source and Paperclip fetches and imports it. Accepted forms include:
+
+- A GitHub repo or subfolder URL (`https://github.com/owner/repo/tree//skills/foo`)
+- A short `owner/repo` or `owner/repo/skill` reference
+- A `skills.sh` URL or an `npx skills add …` command (both resolve to the GitHub source)
+- A raw markdown URL pointing directly at a `SKILL.md`
+
+A repo can contain many skills; the importer discovers every `SKILL.md` under the path
+(optionally filtered to a single `--skill` slug).
+
+- API: `POST /companies/:companyId/skills/import`
+
+### Create a local skill
+
+Author a skill directly in the company library without any external source. This is the
+"new skill" path — you provide the name, description, and markdown body and it's stored
+as a `local_path` / managed-local skill.
+
+- API: `POST /companies/:companyId/skills`
+
+### Scan a project workspace
+
+Agents and projects often already keep skills on disk under conventional folders
+(`skills/`, `.claude/skills/`, `.agents/skills/`, and many other tool-specific roots).
+The project scan walks a workspace, finds those `SKILL.md` directories, and offers to
+import them into the company library, reporting any conflicts or skips.
+
+- API: `POST /companies/:companyId/skills/scan-projects`
+
+## Living with installed skills
+
+Once a skill is in your library, the Store treats it like a small product with a
+lifecycle.
+
+### Versions
+
+Each skill keeps a revision history. Saving a new version snapshots the full file
+inventory (with content) and bumps the revision number, so you can review history and
+roll back.
+
+- List: `GET /companies/:companyId/skills/:skillId/versions`
+- Create: `POST /companies/:companyId/skills/:skillId/versions`
+
+### Updates, drift, and reset
+
+For skills installed from the catalog or an external source, the Store tracks the origin.
+The **update status** endpoint compares your installed copy against the latest upstream
+and reports whether an update is available, whether *you* have locally modified the skill
+(drift), and any hold reason that should block an automatic update.
+
+- Check: `GET /companies/:companyId/skills/:skillId/update-status`
+- Install the upstream update: `POST /companies/:companyId/skills/:skillId/install-update`
+ (with `force` to override local drift)
+- Discard local changes and return to the pristine origin:
+ `POST /companies/:companyId/skills/:skillId/reset`
+
+### Audit
+
+A skill can be audited to compare its installed content hash against its recorded origin
+hash and flag tampering or unexpected drift. The audit returns a verdict and a set of
+codes that the Store surfaces as a health signal.
+
+- API: `POST /companies/:companyId/skills/:skillId/audit`
+
+### Fork
+
+Forking copies an existing skill into a new, independent library entry (optionally with a
+new name, slug, and sharing scope). The fork records what it was forked from, and the
+original's `forkCount` increments. Use this to customize a catalog or community skill
+without losing the ability to see the upstream it came from.
+
+- API: `POST /companies/:companyId/skills/:skillId/fork`
+
+### Stars and comments
+
+Skills are social objects inside the Store. Members can **star** a skill (a per-actor
+toggle that drives the `starCount`) and leave threaded **comments** for discussion and
+review.
+
+- Star / unstar: `POST` / `DELETE /companies/:companyId/skills/:skillId/star`
+- Comments: `GET` / `POST /companies/:companyId/skills/:skillId/comments`,
+ plus `PATCH` and `DELETE` for editing and removing.
+
+## Sharing scope
+
+Every company skill has a **sharing scope** that controls who can see it:
+
+| Scope | Visibility |
+|---|---|
+| `private` | Only the author/owner |
+| `company` | Everyone in the company |
+| `public_link` | Anyone with the generated public share token |
+
+Scope is set when creating, updating, or forking a skill, and the Store's discovery view
+can filter by it.
+
+## How agents actually use installed skills
+
+Installing a skill is not the same as an agent running it. At runtime, a company's
+installed skills are materialized into the agent's workspace as `SKILL.md` directories,
+and the agent's harness loads the **frontmatter `name` + `description`** of each skill as
+routing logic. The agent reads those one-line descriptions to decide *whether* a skill is
+relevant to the current task, and only then loads the full body. (This is why a skill's
+`description` should read as "what this does and when to use it" — it is the index the
+agent searches.)
+
+Skill sync into agent workspaces is governed by a per-instance preference, so an operator
+can control whether and how the company library is pushed down to running agents.
+
+## Reference: API surface
+
+All endpoints are under the company-skills router.
+
+**Catalog (read-only)**
+
+- `GET /skills/catalog` — list the bundled catalog
+- `GET /skills/catalog/:catalogId` — one catalog skill
+- `GET /skills/catalog/:catalogId/files` — its file inventory + content
+
+**Company library**
+
+- `GET /companies/:companyId/skills` — list (supports `q`, `sort`, `categories`, `scope`)
+- `GET /companies/:companyId/skills/categories` — category counts
+- `GET /companies/:companyId/skills/:skillId` — detail
+- `GET /companies/:companyId/skills/:skillId/files` — file inventory + content
+- `POST /companies/:companyId/skills` — create a local skill
+- `PATCH /companies/:companyId/skills/:skillId` — edit metadata / sharing scope
+- `DELETE /companies/:companyId/skills/:skillId` — remove from the library
+- `POST /companies/:companyId/skills/install-catalog` — install a catalog skill
+- `POST /companies/:companyId/skills/import` — import from GitHub / skills.sh / URL
+- `POST /companies/:companyId/skills/scan-projects` — scan workspaces for skills
+- `POST /companies/:companyId/skills/:skillId/fork` — fork a skill
+- `POST /companies/:companyId/skills/:skillId/versions` · `GET …/versions` · `GET …/versions/:versionId`
+- `GET /companies/:companyId/skills/:skillId/update-status`
+- `POST /companies/:companyId/skills/:skillId/install-update`
+- `POST /companies/:companyId/skills/:skillId/reset`
+- `POST /companies/:companyId/skills/:skillId/audit`
+- `POST` / `DELETE /companies/:companyId/skills/:skillId/star`
+- `GET` / `POST /companies/:companyId/skills/:skillId/comments` · `PATCH` / `DELETE …/comments/:commentId`
+
+All mutating endpoints require permission to manage the company's skills and are recorded
+in the company activity log.
+
+## Reference: the catalog package
+
+The catalog is its own publishable package, `@paperclipai/skills-catalog`:
+
+- `catalog/bundled/**` and `catalog/optional/**` — the source skill directories
+- `scripts/build-catalog-manifest.ts` — compiles the directories into `generated/catalog.json`
+- `scripts/validate-catalog.ts` — validates frontmatter, keys, and trust classification
+- `src/index.ts` — exports `catalogManifest`, `catalogSkills`, `getCatalogSkill(id)`, and
+ `resolveCatalogSkillRef(ref)` for resolving a skill by id, key, or slug
+
+To add a skill to the bundled catalog, create the directory with a `SKILL.md`, then run
+the package's `build:manifest` (and `validate`) scripts to regenerate and check the
+manifest.
+
+## See also
+
+- [Writing a Skill](writing-a-skill) — the `SKILL.md` format and authoring best practices
+- [How Agents Work](how-agents-work) — how skills fit into a heartbeat
+
+```
+ (o)___(o)
+ / \
+ | o o |
+ | < |
+ \ \___/ /
+ \_______/
+ / \
+ ~~~ ribbit ~~~ skills! ~~~
+```
diff --git a/docs/guides/board-operator/experimental-features.md b/docs/guides/board-operator/experimental-features.md
new file mode 100644
index 00000000000..eff357c7de0
--- /dev/null
+++ b/docs/guides/board-operator/experimental-features.md
@@ -0,0 +1,52 @@
+---
+title: Experimental Features
+summary: What Paperclip experimental features mean for board operators
+---
+
+Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
+
+## What "experimental" means
+
+When a feature is marked experimental, Paperclip is still evaluating the product shape and implementation details.
+
+- The feature is not part of the stable operator contract yet.
+- UI, API, CLI, behavior, and stored configuration may change as the feature evolves.
+- Paperclip does not promise compatibility, rollback, migration, or long-term support for experimental features.
+
+If you need stable behavior for an important workflow, do not rely on an experimental feature.
+
+## Where you enable them
+
+Board operators enable or disable experiments from **Instance Settings > Experimental** in the app.
+
+The CLI exposes the same surface:
+
+```sh
+pnpm paperclipai instance settings:experimental
+pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
+```
+
+Those commands change the same opt-in settings that the UI manages.
+
+## When to use them
+
+Experimental features are best used when you are:
+
+- evaluating a new capability before wider rollout
+- testing a non-critical workflow
+- comfortable with behavior changes between releases
+- prepared to stop using the feature if it changes or disappears
+
+## Operator expectations
+
+Before enabling an experimental feature:
+
+- decide whether the workflow can tolerate breakage or churn
+- avoid making the feature a dependency for stable production processes
+- keep the scope small until you understand how the feature behaves in your company
+- watch release notes and docs for changes to the feature contract
+
+## Related references
+
+- See the CLI caveat in [Control-Plane Commands](/cli/control-plane-commands).
+- See the repo CLI reference in [`doc/CLI.md`](https://github.com/paperclipai/paperclip/blob/master/doc/CLI.md) when working from the repository.
diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md
index 4850222d0af..1ada4ec87e1 100644
--- a/docs/guides/board-operator/managing-agents.md
+++ b/docs/guides/board-operator/managing-agents.md
@@ -28,10 +28,15 @@ Create agents from the Agents page. Each agent requires:
- **Capabilities** — short description of what this agent does
Common adapter choices:
-- `claude_local` / `codex_local` / `opencode_local` for local coding agents
-- `openclaw_gateway` / `http` for webhook-based external agents
+- `claude_local` / `codex_local` / `opencode_local` / `hermes_local` for local coding agents
+- `hermes_gateway` / `openclaw_gateway` / `http` for webhook-based external agents
- `process` for generic local command execution
+Use `hermes_local` when Paperclip should start the local Hermes CLI. Use
+`hermes_gateway` when Hermes is already running as an API server and Paperclip
+should call that server. Both are built-in adapter types from the unified
+`@paperclipai/hermes-paperclip-adapter` package.
+
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
Paperclip validates the selected model against live `opencode models` output.
diff --git a/docs/pipelines-tutorial.md b/docs/pipelines-tutorial.md
new file mode 100644
index 00000000000..e7fc69a12c6
--- /dev/null
+++ b/docs/pipelines-tutorial.md
@@ -0,0 +1,649 @@
+# Pipelines Tutorial: Release to Published Content
+
+This walkthrough is the CLI/API version of the 12-step release-to-content worked example. It uses three linked pipelines:
+
+- `release-coverage`: one release case answers "is this release covered?"
+- `feature-content`: one feature case per approved feature rolls up content coverage.
+- `content-production`: one content-piece case moves through drafting, assets, assembly, final review, publishing, and a terminal result.
+
+The walkthrough intentionally labels conventions separately from primitives. Those conventions are future primitive candidates: if they hurt, we want to see exactly where.
+
+## Prerequisites
+
+Run this against a dev Paperclip instance with a board token or an agent token that can manage pipelines, routines, and issues.
+
+```sh
+export PAPERCLIP_API_URL=http://localhost:3100
+export PAPERCLIP_COMPANY_ID=
+export PAPERCLIP_API_KEY=
+
+# Optional: assign routine-created drafting issues to a specific agent.
+export DRAFTING_AGENT_ID=
+
+export RUN_KEY="$(date +%Y%m%d%H%M%S)"
+export RELEASE_PIPELINE="release-coverage-$RUN_KEY"
+export FEATURE_PIPELINE="feature-content-$RUN_KEY"
+export CONTENT_PIPELINE="content-production-$RUN_KEY"
+```
+
+## Step 1: Setup The Three Pipelines
+
+Create Release Coverage. It is thin on purpose: the release case stays in `intake` until its feature children are terminal, then `autoAdvanceOnChildrenTerminal` moves it to `covered`.
+
+```sh
+cat > /tmp/release-stages.json <<'JSON'
+[
+ {
+ "key": "intake",
+ "name": "Intake",
+ "kind": "open",
+ "position": 100,
+ "config": { "autoAdvanceOnChildrenTerminal": "covered" }
+ },
+ { "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
+ { "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
+]
+JSON
+
+paperclipai pipelines create \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --key "$RELEASE_PIPELINE" \
+ --name "Release Coverage $RUN_KEY" \
+ --stages-file /tmp/release-stages.json
+```
+
+Create Feature Content. The review stage lets the human approve features into production or drop them from this release.
+
+```sh
+cat > /tmp/feature-stages.json <<'JSON'
+[
+ { "key": "suggesting", "name": "Suggesting", "kind": "open", "position": 100 },
+ {
+ "key": "suggestion_review",
+ "name": "Suggestion Review",
+ "kind": "review",
+ "position": 200,
+ "config": {
+ "approveToStageKey": "producing",
+ "rejectToStageKey": "cancelled",
+ "requestChangesToStageKey": "suggesting",
+ "requireRejectReason": true,
+ "reviewerKind": "human"
+ }
+ },
+ {
+ "key": "producing",
+ "name": "Producing",
+ "kind": "working",
+ "position": 300,
+ "config": { "autoAdvanceOnChildrenTerminal": "covered" }
+ },
+ { "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
+ { "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
+]
+JSON
+
+paperclipai pipelines create \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --key "$FEATURE_PIPELINE" \
+ --name "Feature Content $RUN_KEY" \
+ --stages-file /tmp/feature-stages.json
+```
+
+Create Content Production. `Assets` and `Assembly` are `working` stages, not review stages. `Final Review` is the review stage and has all three exits: approve, request changes, and drop.
+
+```sh
+cat > /tmp/content-stages.json <<'JSON'
+[
+ {
+ "key": "drafting",
+ "name": "Drafting",
+ "kind": "working",
+ "position": 100,
+ "config": { "autonomy": "suggest" }
+ },
+ {
+ "key": "assets",
+ "name": "Assets",
+ "kind": "working",
+ "position": 200
+ },
+ {
+ "key": "assembly",
+ "name": "Assembly",
+ "kind": "working",
+ "position": 300,
+ "config": { "autoAdvanceOnChildrenTerminal": "final_review" }
+ },
+ {
+ "key": "final_review",
+ "name": "Final Review",
+ "kind": "review",
+ "position": 400,
+ "config": {
+ "approveToStageKey": "publishing",
+ "rejectToStageKey": "dropped",
+ "requestChangesToStageKey": "drafting",
+ "requireRejectReason": true,
+ "reviewerKind": "human"
+ }
+ },
+ { "key": "publishing", "name": "Publishing", "kind": "working", "position": 500 },
+ { "key": "published", "name": "Published", "kind": "done", "position": 900 },
+ { "key": "dropped", "name": "Dropped", "kind": "cancelled", "position": 1000 }
+]
+JSON
+
+paperclipai pipelines create \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --key "$CONTENT_PIPELINE" \
+ --name "Content Production $RUN_KEY" \
+ --stages-file /tmp/content-stages.json
+```
+
+Show `enforceTransitions` on one pipeline. The release case can only auto-cover or cancel.
+
+```sh
+cat > /tmp/release-transitions.json <<'JSON'
+{
+ "enforceTransitions": true,
+ "transitions": [
+ { "fromStageKey": "intake", "toStageKey": "covered", "label": "all features terminal" },
+ { "fromStageKey": "intake", "toStageKey": "cancelled", "label": "cancel release coverage" }
+ ]
+}
+JSON
+
+paperclipai pipelines set-transitions \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$RELEASE_PIPELINE" \
+ --file /tmp/release-transitions.json
+```
+
+Add guidance and a drafting routine. The guidance document carries the rubric.
+
+```sh
+cat > /tmp/content-guidance.md <<'MD'
+# Content Production guidance
+
+Final Review has three exits:
+
+- approve to Publishing when the pinned revisions are ready to ship
+- request changes back to Drafting when the same work issue should continue
+- drop to Dropped when the content should not ship
+
+Convention: asset cases store `briefedFromVersion` in `fields` so assembly review can compare a pinned brief against the current upstream case `version`.
+MD
+
+paperclipai pipelines guidance put \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CONTENT_PIPELINE" \
+ --file /tmp/content-guidance.md
+```
+
+```sh
+cat > /tmp/drafting-routine.json < Assets when ready.",
+ "priority": "medium",
+ "status": "active",
+ "concurrencyPolicy": "always_enqueue",
+ "catchUpPolicy": "skip_missed"
+ ${DRAFTING_AGENT_ID:+, "assigneeAgentId": "$DRAFTING_AGENT_ID"}
+}
+JSON
+
+export DRAFTING_ROUTINE_ID="$(
+ paperclipai routine create \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --payload-json "$(jq -c . /tmp/drafting-routine.json)" \
+ --json | jq -r '.id'
+)"
+
+paperclipai pipelines set-automation \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CONTENT_PIPELINE" \
+ --stage drafting \
+ --routine "$DRAFTING_ROUTINE_ID" \
+ --note "Template-versioned with the routine prompt."
+```
+
+**Convention:** v1 "templates" version with the routine prompt plus the batch file below, not with the pipeline. The pipeline `guidance` document carries the durable rubric. This is the accepted divergence from the long-term template-on-pipeline shape.
+
+## Step 2: Trigger, Intake, And Gate
+
+A real system would start with a release-cut routine. Today, the routine fires on a timer or API trigger and creates an intake issue. On that issue, the agent writes a proposal document and asks the board for a checkbox confirmation.
+
+The accepted checkbox selection is represented here by the batch files. That batch file plus the routine prompt is the v1 template convention.
+
+Create the release root:
+
+```sh
+export RELEASE_CASE_ID="$(
+ paperclipai pipelines ingest \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$RELEASE_PIPELINE" \
+ --case-key "release-$RUN_KEY" \
+ --stage intake \
+ --title "Release $RUN_KEY: Pipeline primitives" \
+ --summary "Rollup root for release content coverage." \
+ --fields-json '{"release":"v0.pipeline-tutorial","templateVersionConvention":"routine-prompt"}' \
+ --json | jq -r '.case.id'
+)"
+```
+
+Create two feature cases parented to the release. One is approved, one is dropped.
+
+```sh
+jq -n --arg parent "$RELEASE_CASE_ID" '{
+ items: [
+ {
+ caseKey: "feature-pipelines-ui",
+ title: "Feature: Pipelines UI",
+ summary: "Worth a content package.",
+ parentCaseId: $parent,
+ stageKey: "suggestion_review",
+ fields: { releaseTag: "v0.pipeline-tutorial", source: "release-notes" }
+ },
+ {
+ caseKey: "feature-routine-webhooks",
+ title: "Feature: Routine webhooks",
+ summary: "Rejected by the gate for this release.",
+ parentCaseId: $parent,
+ stageKey: "suggestion_review",
+ fields: { releaseTag: "v0.pipeline-tutorial", source: "release-notes" }
+ }
+ ]
+}' > /tmp/feature-cases.json
+
+paperclipai pipelines ingest-batch \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$FEATURE_PIPELINE" \
+ --file /tmp/feature-cases.json \
+ --json | tee /tmp/feature-cases-result.json
+```
+
+```sh
+feature_case_id() {
+ jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/feature-cases-result.json
+}
+
+export FEATURE_MAIN="$(feature_case_id feature-pipelines-ui)"
+export FEATURE_DROP="$(feature_case_id feature-routine-webhooks)"
+
+jq -n --arg main "$FEATURE_MAIN" --arg drop "$FEATURE_DROP" '{
+ items: [
+ { caseId: $main, decision: "approve", expectedVersion: 1 },
+ { caseId: $drop, decision: "reject", reason: "Fold webhooks into the broader launch post.", expectedVersion: 1 }
+ ]
+}' > /tmp/feature-review.json
+
+paperclipai pipelines review-bulk \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --file /tmp/feature-review.json
+```
+
+Create content cases under the approved feature. `launch-tweet` declares `blockedByCaseKeys: ["blog-post"]`; the CLI resolves that key to the blog case in the same batch.
+
+```sh
+jq -n --arg parent "$FEATURE_MAIN" '{
+ items: [
+ {
+ caseKey: "blog-post",
+ title: "Launch blog post",
+ summary: "Draft the release narrative.",
+ parentCaseId: $parent,
+ stageKey: "drafting",
+ fields: {
+ contentType: "blog",
+ typedWorkRefs: { draftPath: "workspaces/release/blog.md" },
+ briefedFromVersion: null
+ }
+ },
+ {
+ caseKey: "changelog-entry",
+ title: "Product changelog",
+ summary: "Compact changelog entry.",
+ parentCaseId: $parent,
+ stageKey: "drafting",
+ fields: {
+ contentType: "changelog",
+ typedWorkRefs: { draftPath: "workspaces/release/changelog.md" },
+ briefedFromVersion: null
+ }
+ },
+ {
+ caseKey: "launch-tweet",
+ title: "Launch tweet",
+ summary: "Tweet after the blog is approved.",
+ parentCaseId: $parent,
+ stageKey: "drafting",
+ blockedByCaseKeys: ["blog-post"],
+ fields: {
+ contentType: "social",
+ typedWorkRefs: { draftPath: "workspaces/release/tweet.md" },
+ briefedFromVersion: 1
+ }
+ }
+ ]
+}' > /tmp/content-cases.json
+
+paperclipai pipelines ingest-batch \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CONTENT_PIPELINE" \
+ --file /tmp/content-cases.json \
+ --json | tee /tmp/content-cases-result.json
+```
+
+**Convention:** `typedWorkRefs` and `briefedFromVersion` are ordinary case `fields`, not new primitives. They document how this case type points at work and how downstream asset briefs pin an upstream version.
+
+## Step 3: Readiness Suggestion
+
+The drafting agent should not silently move the case. It suggests `Drafting -> Assets` with a rationale, and the human accepts it.
+
+```sh
+content_case_id() {
+ jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/content-cases-result.json
+}
+
+export BLOG_CASE="$(content_case_id blog-post)"
+export CHANGELOG_CASE="$(content_case_id changelog-entry)"
+export TWEET_CASE="$(content_case_id launch-tweet)"
+
+export SUGGESTION_ID="$(
+ paperclipai pipelines case suggest \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --to assets \
+ --rationale "Draft is stable enough to brief asset work." \
+ --confidence 0.9 \
+ --json | jq -r '.suggestion.id'
+)"
+
+paperclipai pipelines case resolve-suggestion \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --suggestion "$SUGGESTION_ID" \
+ --accept \
+ --expected-version 1
+```
+
+## Step 4: Parallel Editing And Drift
+
+The draft can still change while dependent work exists. A material update to the upstream case posts a drift comment on dependent linked work issues.
+
+```sh
+export TWEET_WORK_ISSUE="$(
+ paperclipai issue create \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ --title "Work issue for launch tweet $RUN_KEY" \
+ --description "Receives drift comments from the upstream blog case." \
+ --status todo \
+ --priority low \
+ --json | jq -r '.id'
+)"
+
+curl -sS -X POST \
+ -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
+ -H "Content-Type: application/json" \
+ --data "$(jq -cn --arg issueId "$TWEET_WORK_ISSUE" '{ issueId: $issueId, role: "work" }')" \
+ "$PAPERCLIP_API_URL/api/cases/$TWEET_CASE/issue-links" >/dev/null
+
+paperclipai pipelines case edit \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --expected-version 2 \
+ --summary "Draft changed while dependent tweet work was already briefed." \
+ --fields-json '{"contentType":"blog","typedWorkRefs":{"draftPath":"workspaces/release/blog.md"},"briefedFromVersion":null,"materialChange":"new-positioning"}'
+```
+
+If a worker tries to patch with the stale version, the API returns `409` with `code=version_conflict`, the current version, and the current stage. Recovery is to re-read the case and retry against the current version.
+
+```sh
+paperclipai pipelines case edit \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --expected-version 2 \
+ --title "Stale edit"
+
+# Recovery:
+paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json
+```
+
+## Step 5: Assets
+
+The Assets automation creates asset cases under the feature. In v1 the tutorial uses an explicit batch file; in the product, this is the stage-template convention.
+
+```sh
+export BLOG_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
+
+jq -n --arg parent "$FEATURE_MAIN" --argjson briefVersion "$BLOG_VERSION" '{
+ items: [
+ {
+ caseKey: "blog-hero-image",
+ title: "Hero image",
+ parentCaseId: $parent,
+ stageKey: "assets",
+ fields: { assetType: "image", briefedFromVersion: $briefVersion }
+ },
+ {
+ caseKey: "blog-social-card",
+ title: "Social card",
+ parentCaseId: $parent,
+ stageKey: "assets",
+ fields: { assetType: "image", briefedFromVersion: $briefVersion }
+ }
+ ]
+}' > /tmp/asset-cases.json
+
+paperclipai pipelines ingest-batch \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CONTENT_PIPELINE" \
+ --file /tmp/asset-cases.json \
+ --json | tee /tmp/asset-cases-result.json
+```
+
+```sh
+asset_case_id() {
+ jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/asset-cases-result.json
+}
+
+export HERO_CASE="$(asset_case_id blog-hero-image)"
+export CARD_CASE="$(asset_case_id blog-social-card)"
+
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$HERO_CASE" --to published --expected-version 1 --reason "Hero image done."
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CARD_CASE" --to dropped --expected-version 1 --reason "Social card not needed."
+```
+
+When both asset cases are terminal, move the blog case to `assembly`.
+
+```sh
+export BLOG_ASSETS_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
+
+paperclipai pipelines case transition \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --to assembly \
+ --expected-version "$BLOG_ASSETS_VERSION" \
+ --reason "Assets complete; assemble the package."
+```
+
+## Step 6: Assembly And Auto-Advance To Final Review
+
+Assembly is also a `working` stage. This is the `autoAdvanceOnChildrenTerminal` gate: create a package child case, complete it, and the blog case auto-advances into `final_review`.
+
+```sh
+jq -n --arg parent "$BLOG_CASE" '{
+ items: [
+ {
+ caseKey: "blog-assembly-package",
+ title: "Assembled blog package",
+ parentCaseId: $parent,
+ stageKey: "assembly",
+ fields: { packageType: "blog", assembledFrom: ["blog-hero-image", "blog-social-card"] }
+ }
+ ]
+}' > /tmp/assembly-cases.json
+
+paperclipai pipelines ingest-batch \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CONTENT_PIPELINE" \
+ --file /tmp/assembly-cases.json \
+ --json | tee /tmp/assembly-cases-result.json
+
+export ASSEMBLY_CASE="$(jq -r '.[0].case.id' /tmp/assembly-cases-result.json)"
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$ASSEMBLY_CASE" --to published --expected-version 1 --reason "Assembly complete."
+```
+
+## Step 7: Blocker Guard
+
+The tweet is blocked by the blog case through `blockedByCaseKeys`. This transition fails with `409 code=blocked` until the blog reaches a `done` terminal stage.
+
+```sh
+paperclipai pipelines case transition \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$TWEET_CASE" \
+ --to assets \
+ --expected-version 1 \
+ --reason "Try before upstream blog is published."
+```
+
+## Step 8: Final Review Approve
+
+Approve the blog in Final Review, then publish it.
+
+```sh
+export BLOG_REVIEW_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
+
+paperclipai pipelines case review \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --approve \
+ --expected-version "$BLOG_REVIEW_VERSION"
+
+paperclipai pipelines case transition \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$BLOG_CASE" \
+ --to published \
+ --expected-version "$((BLOG_REVIEW_VERSION + 1))" \
+ --reason "Approved package published."
+```
+
+## Step 9: Final Review Request Changes
+
+The changelog demonstrates the edit loop: Final Review requests changes, the same case re-enters `drafting`, the same work references continue, and the case comes back to Final Review for approval.
+
+```sh
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to final_review --expected-version 1 --reason "Draft ready for final review."
+
+paperclipai pipelines case review \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CHANGELOG_CASE" \
+ --request-changes \
+ --reason "Tighten the framing before publishing." \
+ --expected-version 2
+
+paperclipai pipelines case edit \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CHANGELOG_CASE" \
+ --expected-version 3 \
+ --summary "Revised changelog entry after requested changes." \
+ --fields-json '{"contentType":"changelog","typedWorkRefs":{"draftPath":"workspaces/release/changelog.md"},"changeRequestAddressed":true}'
+
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to final_review --expected-version 4 --reason "Revised draft ready."
+paperclipai pipelines case review -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --approve --expected-version 5
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to published --expected-version 6 --reason "Published after request-changes loop."
+```
+
+## Step 10: Final Review Drop
+
+Now that the blog blocker is done, the tweet can reach Final Review. The reviewer drops it, which is terminal and still counts toward rollup completion.
+
+```sh
+paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$TWEET_CASE" --to final_review --expected-version 1 --reason "Blog blocker is now done."
+
+paperclipai pipelines case review \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$TWEET_CASE" \
+ --reject \
+ --reason "Drop this tweet; blog already covers the announcement." \
+ --expected-version 2
+```
+
+## Step 11: Rollup
+
+At this point:
+
+- content cases are `published` or `dropped`
+- the approved feature case auto-advanced to `covered`
+- the dropped feature case is terminal
+- the release case auto-advanced to `covered`
+
+Inspect the release rollup:
+
+```sh
+paperclipai pipelines case rollup \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$RELEASE_CASE_ID" \
+ --json
+```
+
+Expected shape:
+
+```json
+{
+ "total": 8,
+ "done": 5,
+ "cancelled": 3,
+ "open": 0,
+ "complete": true
+}
+```
+
+## Step 12: Reflection Feed
+
+Reflection can pull provenance from case events:
+
+```sh
+paperclipai pipelines case events \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$CHANGELOG_CASE" \
+ --json
+```
+
+Look for `review_decided` events where `payload.decision` is `request_changes`, `approve`, or `reject`. Rejection and change-request reasons are the feed for improving skills, routine prompts, and pipeline guidance.
+
+For rollup provenance:
+
+```sh
+paperclipai pipelines case events \
+ -C "$PAPERCLIP_COMPANY_ID" \
+ "$RELEASE_CASE_ID" \
+ --json
+```
+
+Look for `children_terminal` followed by the auto `transitioned` event.
+
+## Scripted Smoke
+
+Run the same flow end to end:
+
+```sh
+PAPERCLIP_API_URL=http://localhost:3100 \
+PAPERCLIP_COMPANY_ID= \
+PAPERCLIP_API_KEY= \
+pnpm smoke:pipelines-tutorial
+```
+
+The smoke asserts:
+
+- the three pipelines are created with the expected stages
+- feature review approves one feature and rejects one
+- batch ingest wires `blockedByCaseKeys`
+- readiness uses `suggest-transition` plus acceptance
+- upstream drift posts a system comment to a linked work issue
+- stale edits fail with `409 code=version_conflict`
+- the Assembly child-terminal gate auto-advances the parent into Final Review
+- Final Review approve, request-changes, and drop outcomes all work
+- the release rollup is complete with the expected done/cancelled split
diff --git a/package.json b/package.json
index 28e27bc944c..75007b518d4 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
+ "postinstall": "node scripts/link-plugin-dev-sdk.mjs",
"preflight:workspace-links": "node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts",
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
@@ -37,12 +38,16 @@
"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",
+ "test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs",
"docs:dev": "cd docs && npx mintlify dev",
+ "smoke:hermes-gateway-join": "./scripts/smoke/hermes-gateway-join.sh",
+ "smoke:hermes-gateway-e2e": "./scripts/smoke/hermes-gateway-e2e.sh",
"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:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.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 scripts/check-no-git-push.test.mjs scripts/release-lib.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 scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js",
"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",
@@ -55,7 +60,7 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"cross-env": "^10.1.0",
- "esbuild": "^0.28.0",
+ "esbuild": "^0.28.1",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
@@ -68,7 +73,9 @@
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
},
"overrides": {
- "rollup": ">=4.59.0"
+ "rollup": ">=4.59.0",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7"
}
}
}
diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json
index 10bbecf9023..5eaf1004884 100644
--- a/packages/adapter-utils/package.json
+++ b/packages/adapter-utils/package.json
@@ -40,7 +40,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
- "@types/node": "^24.6.0",
+ "@types/node": "^22.19.21",
"typescript": "^5.7.3"
}
}
diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts
index df0c55d2e0f..1d97a20a60f 100644
--- a/packages/adapter-utils/src/command-managed-runtime.test.ts
+++ b/packages/adapter-utils/src/command-managed-runtime.test.ts
@@ -1,15 +1,118 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { execFile as execFileCallback } from "node:child_process";
+import { execFile as execFileCallback, spawn } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
-import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
+import {
+ createCommandManagedRuntimeClient,
+ prepareCommandManagedRuntime,
+ type CommandManagedRuntimeRunner,
+} from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
+interface SpawnRunnerHandle {
+ runner: CommandManagedRuntimeRunner;
+ calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }>;
+}
+
+// A runner that actually executes the shell scripts (piping stdin through a real
+// pipe so multi-MB payloads work) and replays stdout through onLog in several
+// chunks so the streaming readFile byte-counter is exercised.
+function makeSpawnRunner(options: {
+ supportsSingleStreamStdinProgress?: boolean;
+ maxStdoutBytes?: number;
+} = {}): SpawnRunnerHandle {
+ const calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }> = [];
+ const runner: CommandManagedRuntimeRunner = {
+ supportsSingleStreamStdinProgress: options.supportsSingleStreamStdinProgress,
+ execute: async (input) =>
+ await new Promise((resolve) => {
+ calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin });
+ const startedAt = new Date().toISOString();
+ const command =
+ input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
+ const child = spawn(command, input.args ?? [], {
+ cwd: input.cwd,
+ env: { ...process.env, ...input.env },
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString("utf8");
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk.toString("utf8");
+ });
+ child.on("error", () => {
+ resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt });
+ });
+ child.on("close", async (code) => {
+ if (
+ options.maxStdoutBytes != null &&
+ Buffer.byteLength(stdout, "utf8") > options.maxStdoutBytes
+ ) {
+ resolve({
+ exitCode: 1,
+ signal: null,
+ timedOut: false,
+ stdout,
+ stderr: `stdout exceeded ${options.maxStdoutBytes} bytes`,
+ pid: child.pid ?? null,
+ startedAt,
+ });
+ return;
+ }
+ if (input.onLog && stdout.length > 0) {
+ const chunkSize = Math.max(1, Math.ceil(stdout.length / 4));
+ for (let offset = 0; offset < stdout.length; offset += chunkSize) {
+ await input.onLog("stdout", stdout.slice(offset, offset + chunkSize));
+ }
+ }
+ resolve({
+ exitCode: code ?? 0,
+ signal: null,
+ timedOut: false,
+ stdout,
+ stderr,
+ pid: child.pid ?? null,
+ startedAt,
+ });
+ });
+ if (input.stdin != null) child.stdin.write(input.stdin);
+ child.stdin.end();
+ }),
+ };
+ return { runner, calls };
+}
+
+function toArrayBuffer(buffer: Buffer): ArrayBuffer {
+ return Uint8Array.from(buffer).buffer;
+}
+
+async function withBase64StringByteLimit(limitBytes: number, fn: () => Promise): Promise {
+ const originalToString = Buffer.prototype.toString;
+ Buffer.prototype.toString = function patchedToString(
+ this: Buffer,
+ encoding?: BufferEncoding,
+ start?: number,
+ end?: number,
+ ) {
+ if (encoding === "base64" && this.byteLength > limitBytes) {
+ throw new Error(`test guard: attempted to base64-encode ${this.byteLength} bytes at once`);
+ }
+ return originalToString.call(this, encoding, start, end);
+ } as typeof Buffer.prototype.toString;
+ try {
+ return await fn();
+ } finally {
+ Buffer.prototype.toString = originalToString;
+ }
+}
+
describe("command managed runtime", () => {
const cleanupDirs: string[] = [];
@@ -117,7 +220,9 @@ describe("command managed runtime", () => {
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
- expect(calls.every((call) => call.stdin == null)).toBe(true);
+ // The single-stream upload pipes the tarball through exactly one stdin-backed
+ // process (the speed fix); nothing else streams stdin.
+ expect(calls.filter((call) => call.stdin != null).length).toBe(1);
await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
@@ -129,7 +234,9 @@ describe("command managed runtime", () => {
.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
- expect(calls.every((call) => call.stdin == null)).toBe(true);
+ // Restore streams the download through `base64`/onLog (no stdin), so the only
+ // stdin-backed call remains the single upload from prepare.
+ expect(calls.filter((call) => call.stdin != null).length).toBe(1);
});
it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => {
@@ -143,64 +250,7 @@ describe("command managed runtime", () => {
await mkdir(remoteBaseDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
- const calls: Array<{
- command: string;
- args?: string[];
- cwd?: string;
- env?: Record;
- stdin?: string;
- timeoutMs?: number;
- }> = [];
- const runner = {
- execute: async (input: {
- command: string;
- args?: string[];
- cwd?: string;
- env?: Record;
- stdin?: string;
- timeoutMs?: number;
- }): Promise => {
- calls.push({ ...input });
- const startedAt = new Date().toISOString();
- try {
- const result = await execFile(input.command === "sh" ? "/bin/sh" : input.command, input.args ?? [], {
- cwd: input.cwd,
- env: {
- ...process.env,
- ...input.env,
- },
- maxBuffer: 32 * 1024 * 1024,
- timeout: input.timeoutMs,
- });
- return {
- exitCode: 0,
- signal: null,
- timedOut: false,
- stdout: result.stdout,
- stderr: result.stderr,
- pid: null,
- startedAt,
- };
- } catch (error) {
- const err = error as NodeJS.ErrnoException & {
- stdout?: string;
- stderr?: string;
- code?: string | number | null;
- signal?: NodeJS.Signals | null;
- killed?: boolean;
- };
- return {
- exitCode: typeof err.code === "number" ? err.code : null,
- signal: err.signal ?? null,
- timedOut: Boolean(err.killed && input.timeoutMs),
- stdout: err.stdout ?? "",
- stderr: err.stderr ?? "",
- pid: null,
- startedAt,
- };
- }
- },
- };
+ const { runner, calls } = makeSpawnRunner();
await prepareCommandManagedRuntime({
runner,
@@ -217,4 +267,132 @@ describe("command managed runtime", () => {
expect(calls.every((call) => call.cwd === "/")).toBe(true);
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
});
+
+ it("uploads a multi-MB payload in a single process and preserves exact bytes", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-"));
+ cleanupDirs.push(rootDir);
+ const remotePath = path.join(rootDir, "nested", "payload.bin");
+
+ // ~3 MB of every byte value so the test catches any non-binary-safe handling.
+ const payload = Buffer.alloc(3 * 1024 * 1024);
+ for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
+
+ const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
+ const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
+
+ const progress: Array<{ done: number; total: number | null }> = [];
+ await withBase64StringByteLimit(4 * 1024 * 1024, async () => {
+ await client.writeFile(remotePath, toArrayBuffer(payload), {
+ onProgress: (done, total) => {
+ progress.push({ done, total });
+ },
+ });
+ });
+
+ // Exactly one upload process: O(1) round-trips regardless of payload size.
+ expect(calls.length).toBe(1);
+ expect(calls[0].stdin).toBeTypeOf("string");
+
+ const written = await readFile(remotePath);
+ expect(written.equals(payload)).toBe(true);
+
+ // Progress is monotonically non-decreasing and reaches the total.
+ expect(progress.length).toBeGreaterThan(0);
+ for (let i = 1; i < progress.length; i++) {
+ expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
+ }
+ expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length });
+ });
+
+ it("falls back to chunked upload progress when the runner cannot report mid-stream stdin progress", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-fallback-"));
+ cleanupDirs.push(rootDir);
+ const remotePath = path.join(rootDir, "nested", "payload.bin");
+
+ const payload = Buffer.alloc(12 * 1024 * 1024);
+ for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
+
+ const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: false });
+ const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
+
+ const progress: Array<{ done: number; total: number | null }> = [];
+ await client.writeFile(remotePath, toArrayBuffer(payload), {
+ onProgress: (done, total) => {
+ progress.push({ done, total });
+ },
+ });
+
+ const written = await readFile(remotePath);
+ expect(written.equals(payload)).toBe(true);
+
+ // Provider-backed sandbox runners cannot surface mid-flight progress for a
+ // single stdin RPC, so we intentionally use several large append commands.
+ expect(calls.length).toBeGreaterThan(2);
+ const stdinCalls = calls.filter((call) => call.stdin != null);
+ expect(stdinCalls.length).toBeGreaterThan(2);
+ expect(stdinCalls.every((call) => Buffer.byteLength(call.stdin ?? "", "utf8") <= 4.1 * 1024 * 1024)).toBe(true);
+ expect(progress.length).toBeGreaterThan(2);
+ for (let i = 1; i < progress.length; i++) {
+ expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
+ }
+ expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length });
+ });
+
+ it("falls back to bounded chunks when the runner does not explicitly opt in", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-fallback-no-progress-"));
+ cleanupDirs.push(rootDir);
+ const remotePath = path.join(rootDir, "nested", "payload.bin");
+
+ const payload = Buffer.alloc(12 * 1024 * 1024);
+ for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
+
+ const { runner, calls } = makeSpawnRunner();
+ const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
+
+ await withBase64StringByteLimit(4 * 1024 * 1024, async () => {
+ await client.writeFile(remotePath, toArrayBuffer(payload));
+ });
+
+ const written = await readFile(remotePath);
+ expect(written.equals(payload)).toBe(true);
+
+ // A runner that doesn't mark single-stream stdin support must avoid passing
+ // the whole base64 archive as one string, so we expect multiple append calls.
+ const stdinCalls = calls.filter((call) => call.stdin != null);
+ expect(stdinCalls.length).toBeGreaterThan(1);
+ expect(stdinCalls.every((call) => Buffer.byteLength(call.stdin ?? "", "utf8") <= 4.1 * 1024 * 1024)).toBe(true);
+ });
+
+ it("downloads in bounded stdout chunks and reports monotonic byte progress to the total", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-read-"));
+ cleanupDirs.push(rootDir);
+ const remotePath = path.join(rootDir, "download.bin");
+
+ const payload = Buffer.alloc(7 * 1024 * 1024);
+ for (let i = 0; i < payload.length; i++) payload[i] = (i * 7) % 256;
+ await writeFile(remotePath, payload);
+
+ const { runner, calls } = makeSpawnRunner({ maxStdoutBytes: 5 * 1024 * 1024 });
+ const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
+
+ const progress: Array<{ done: number; total: number | null }> = [];
+ const bytes = await client.readFile(remotePath, {
+ onProgress: (done, total) => {
+ progress.push({ done, total });
+ },
+ });
+
+ expect(Buffer.from(bytes as ArrayBuffer).equals(payload)).toBe(true);
+
+ // The old single `base64 < file` path would exceed the runner's stdout cap.
+ // The bounded path reads with several small `dd | base64` commands instead.
+ expect(calls.some((call) => call.args?.join(" ").includes("base64 <"))).toBe(false);
+ expect(calls.filter((call) => call.args?.join(" ").includes("dd if=")).length).toBeGreaterThan(1);
+ expect(progress.length).toBeGreaterThan(1);
+ for (let i = 1; i < progress.length; i++) {
+ expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
+ }
+ expect(progress.every((entry) => entry.total === payload.length)).toBe(true);
+ expect(progress.at(-1)?.done).toBe(payload.length);
+ });
});
diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts
index dbcd5ab928c..beb0d4dbdfe 100644
--- a/packages/adapter-utils/src/command-managed-runtime.ts
+++ b/packages/adapter-utils/src/command-managed-runtime.ts
@@ -8,8 +8,16 @@ import {
} from "./sandbox-managed-runtime.js";
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
import type { RunProcessResult } from "./server-utils.js";
+import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js";
export interface CommandManagedRuntimeRunner {
+ /**
+ * True only when `execute({ stdin })` can surface useful in-flight progress
+ * for a single stdin-backed command. Provider-backed sandbox runners usually
+ * complete the entire RPC before returning, so they should leave this false
+ * and let the caller choose a chunked upload path when progress is requested.
+ */
+ supportsSingleStreamStdinProgress?: boolean;
execute(input: {
command: string;
args?: string[];
@@ -40,7 +48,20 @@ function mergeRuntimeExcludes(entries: string[] | undefined): string[] {
return [...new Set([".paperclip-runtime", ...(entries ?? [])])];
}
-const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
+// Largest base64 body we hand to the runner as a single stdin string. Normal
+// multi-MB workspace/asset tarballs stay well under this and upload in one
+// round-trip; anything larger uses the bounded chunked-append fallback so a
+// runaway stdin string can't blow the runner/provider RPC limits.
+const REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES = 96 * 1024 * 1024;
+// Fallback chunk size (base64 bytes). Kept a multiple of 4 so each chunk is a
+// self-contained base64 unit that decodes cleanly on its own.
+const REMOTE_WRITE_FALLBACK_BASE64_CHUNK_SIZE = 4 * 1024 * 1024;
+const REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE = (REMOTE_WRITE_FALLBACK_BASE64_CHUNK_SIZE / 4) * 3;
+const REMOTE_READ_CHUNK_BYTES = REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE;
+
+function base64EncodedLength(byteLength: number): number {
+ return Math.ceil(byteLength / 3) * 4;
+}
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
if (Buffer.isBuffer(bytes)) return bytes;
@@ -62,13 +83,21 @@ export function createCommandManagedRuntimeClient(input: {
shellCommand?: "bash" | "sh" | null;
}): SandboxManagedRuntimeClient {
const shellCommand = preferredShellForSandbox(input.shellCommand);
- const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
+ const runShell = async (
+ script: string,
+ opts: {
+ stdin?: string;
+ timeoutMs?: number;
+ onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise;
+ } = {},
+ ) => {
const result = await input.runner.execute({
command: shellCommand,
args: shellCommandArgs(script),
cwd: input.commandCwd,
stdin: opts.stdin,
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
+ onLog: opts.onLog,
});
requireSuccessfulResult(result, script);
return result;
@@ -78,25 +107,89 @@ export function createCommandManagedRuntimeClient(input: {
makeDir: async (remotePath) => {
await runShell(`mkdir -p ${shellQuote(remotePath)}`);
},
- writeFile: async (remotePath, bytes) => {
- const body = toBuffer(bytes).toString("base64");
+ writeFile: async (remotePath, bytes, options) => {
+ const buffer = toBuffer(bytes);
+ const total = buffer.byteLength;
+ const encodedLength = base64EncodedLength(total);
const remoteDir = path.posix.dirname(remotePath);
- const remoteTempPath = `${remotePath}.paperclip-upload.b64`;
+ const remoteTempPath = `${remotePath}.paperclip-upload`;
+ const canUseSingleStreamProgressPath = input.runner.supportsSingleStreamStdinProgress === true;
- await runShell(
- `mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
- );
- for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
- const chunk = body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE);
- await runShell(`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(remoteTempPath)}`);
+ // Primary path: a single round-trip. Stream the entire base64 body to one
+ // `base64 -d` process via stdin, decode straight into a temp file, then
+ // atomically rename into place. This replaces the previous loop that did
+ // one `printf >> tmpfile` shell round-trip per 32 KB — thousands of serial
+ // processes for a large workspace — with exactly one process.
+ if (
+ encodedLength <= REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES &&
+ canUseSingleStreamProgressPath
+ ) {
+ const body = buffer.toString("base64");
+ await options?.onProgress?.(0, total);
+ await runShell(
+ `mkdir -p ${shellQuote(remoteDir)} && ` +
+ `base64 -d > ${shellQuote(remoteTempPath)} && ` +
+ `mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`,
+ { stdin: body },
+ );
+ await options?.onProgress?.(total, total);
+ return;
}
+
+ // Bounded fallback for payloads too large to hand the runner as one stdin
+ // string: append the base64 body to a remote temp file in large chunks
+ // (orders of magnitude fewer round-trips than the old 32 KB loop), decoding
+ // each self-contained chunk on arrival and emitting progress per write,
+ // then atomically rename into place.
await runShell(
- `base64 -d < ${shellQuote(remoteTempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(remoteTempPath)}`,
+ `mkdir -p ${shellQuote(remoteDir)} && ` +
+ `rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
);
+ for (let offset = 0; offset < total; offset += REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE) {
+ const end = Math.min(total, offset + REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE);
+ const chunk = buffer.subarray(offset, end).toString("base64");
+ await runShell(`base64 -d >> ${shellQuote(remoteTempPath)}`, { stdin: chunk });
+ await options?.onProgress?.(end, total);
+ }
+ await runShell(`mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`);
+ await options?.onProgress?.(total, total);
},
- readFile: async (remotePath) => {
- const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
- return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
+ readFile: async (remotePath, options) => {
+ // Chunked reads intentionally query the remote size first, even without
+ // a progress sink, so each sandbox RPC stays bounded and truncation is
+ // detected without materializing the whole file as one stdout string.
+ const sizeResult = await runShell(`wc -c < ${shellQuote(remotePath)}`);
+ const totalBytes = Number.parseInt(sizeResult.stdout.trim(), 10);
+ if (!Number.isFinite(totalBytes) || totalBytes < 0) {
+ throw new Error(`Could not determine remote file size for ${remotePath}`);
+ }
+
+ // Read in bounded remote chunks so the runner never has to materialize a
+ // single base64 stdout string for the whole archive. The client API still
+ // returns the decoded file as a Buffer, but every command result stays
+ // small enough for provider-backed sandbox RPCs.
+ const decodedChunks: Buffer[] = [];
+ let decodedSoFar = 0;
+ if (totalBytes === 0) {
+ await options?.onProgress?.(0, 0);
+ return Buffer.alloc(0);
+ }
+ for (let chunkIndex = 0; decodedSoFar < totalBytes; chunkIndex++) {
+ const result = await runShell(
+ `dd if=${shellQuote(remotePath)} bs=${REMOTE_READ_CHUNK_BYTES} skip=${chunkIndex} count=1 2>/dev/null | base64`,
+ );
+ const chunk = Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
+ if (chunk.byteLength === 0) break;
+ decodedChunks.push(chunk);
+ decodedSoFar += chunk.byteLength;
+ await options?.onProgress?.(Math.min(decodedSoFar, totalBytes), totalBytes);
+ }
+ const out = Buffer.concat(decodedChunks);
+ if (out.byteLength !== totalBytes) {
+ throw new Error(`Remote file read was truncated for ${remotePath}: ${out.byteLength}/${totalBytes} bytes`);
+ }
+ await options?.onProgress?.(out.byteLength, totalBytes);
+ return out;
},
listFiles: async (remotePath) => {
const result = await runShell(
@@ -146,6 +239,10 @@ export async function prepareCommandManagedRuntime(input: {
installCommand?: string | null;
/** When provided alongside `installCommand`, skip the install if `command -v ` succeeds. */
detectCommand?: string | null;
+ // Upload progress sink. Forwarded to prepareSandboxManagedRuntime; the child
+ // task wires it into the byte-counting writeFile/readFile transport.
+ onProgress?: RuntimeProgressSink;
+ onRuntimeProgress?: RuntimeStatusSink;
}): Promise {
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
@@ -193,6 +290,8 @@ export async function prepareCommandManagedRuntime(input: {
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
+ onProgress: input.onProgress,
+ onRuntimeProgress: input.onRuntimeProgress,
});
}
}
@@ -227,5 +326,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
+ onProgress: input.onProgress,
+ onRuntimeProgress: input.onRuntimeProgress,
});
}
diff --git a/packages/adapter-utils/src/exclude-patterns.ts b/packages/adapter-utils/src/exclude-patterns.ts
new file mode 100644
index 00000000000..5cc9eeb1a85
--- /dev/null
+++ b/packages/adapter-utils/src/exclude-patterns.ts
@@ -0,0 +1,28 @@
+export function isRelativePathOrDescendant(relative: string, candidate: string): boolean {
+ return relative === candidate || relative.startsWith(`${candidate}/`);
+}
+
+function pathContainsSegmentOrDescendant(relative: string, segment: string): boolean {
+ return relative === segment ||
+ relative.startsWith(`${segment}/`) ||
+ relative.endsWith(`/${segment}`) ||
+ relative.includes(`/${segment}/`);
+}
+
+export function excludePatternMatches(relative: string, pattern: string): boolean {
+ if (pattern.startsWith("*/") && pattern.endsWith("/*")) {
+ return pathContainsSegmentOrDescendant(relative, pattern.slice(2, -2));
+ }
+ if (pattern.startsWith("*/")) {
+ return pathContainsSegmentOrDescendant(relative, pattern.slice(2));
+ }
+ if (pattern.endsWith("/*")) {
+ const base = pattern.slice(0, -2);
+ return relative.startsWith(`${base}/`);
+ }
+ return isRelativePathOrDescendant(relative, pattern);
+}
+
+export function shouldExcludePath(relative: string, exclude: readonly string[]): boolean {
+ return exclude.some((entry) => excludePatternMatches(relative, entry));
+}
diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts
index 8b6d5bbbbdf..6978e8e9034 100644
--- a/packages/adapter-utils/src/execution-target.ts
+++ b/packages/adapter-utils/src/execution-target.ts
@@ -28,6 +28,9 @@ import {
} from "./server-utils.js";
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
+import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js";
+
+export type { RuntimeProgressSink } from "./runtime-progress.js";
export interface AdapterLocalExecutionTarget {
kind: "local";
@@ -70,7 +73,7 @@ export interface PreparedAdapterExecutionTargetRuntime {
workspaceRemoteDir: string | null;
runtimeRootDir: string | null;
assetDirs: Record;
- restoreWorkspace(): Promise;
+ restoreWorkspace(onProgress?: RuntimeProgressSink): Promise;
}
export interface AdapterExecutionTargetProcessOptions {
@@ -80,6 +83,7 @@ export interface AdapterExecutionTargetProcessOptions {
timeoutSec: number;
graceSec: number;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise;
+ onRuntimeProgress?: RuntimeStatusSink;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise;
terminalResultCleanup?: TerminalResultCleanupOptions;
}
@@ -404,6 +408,10 @@ export async function runAdapterExecutionTargetProcess(
if (target?.kind === "remote" && target.transport === "sandbox") {
const runner = requireSandboxRunner(target);
const env = sanitizeRemoteExecutionEnv(options.env);
+ await options.onRuntimeProgress?.({
+ phase: "adapter_startup",
+ message: "Starting adapter in sandbox",
+ });
return await runner.execute({
command,
args,
@@ -930,6 +938,12 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
installCommand?: string | null;
/** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */
detectCommand?: string | null;
+ // Optional progress sink for the workspace/asset upload. The returned
+ // `restoreWorkspace(onProgress?)` accepts its own sink for teardown. Both are
+ // forwarded down to the transport so the sandbox/SSH children can attach byte
+ // counters without further changes here.
+ onProgress?: RuntimeProgressSink;
+ onRuntimeProgress?: RuntimeStatusSink;
}): Promise {
const target = input.target ?? { kind: "local" as const };
if (target.kind === "local") {
@@ -950,6 +964,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: input.workspaceRemoteDir,
assets: input.assets,
+ onProgress: input.onProgress,
});
return {
target,
@@ -980,6 +995,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
assets: input.assets,
installCommand: input.installCommand,
detectCommand: input.detectCommand,
+ onProgress: input.onProgress,
+ onRuntimeProgress: input.onRuntimeProgress,
});
return {
target,
diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts
new file mode 100644
index 00000000000..7a7b8c646a3
--- /dev/null
+++ b/packages/adapter-utils/src/git-workspace-sync.test.ts
@@ -0,0 +1,128 @@
+import { execFile as execFileCallback } from "node:child_process";
+import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ buildRemoteGitDeltaBundleScript,
+ createImportedGitRef,
+ createRemoteGitExportRef,
+ deleteLocalGitRef,
+ fetchGitBundleIntoLocalRef,
+ readGitWorkspaceSnapshot,
+ runLocalGit,
+ withShallowGitWorkspaceClone,
+} from "./git-workspace-sync.js";
+
+const execFile = promisify(execFileCallback);
+
+async function git(cwd: string, args: string[]): Promise {
+ return (await runLocalGit(cwd, args)).stdout.trim();
+}
+
+describe("git workspace sync", () => {
+ const cleanupDirs: string[] = [];
+
+ afterEach(async () => {
+ while (cleanupDirs.length > 0) {
+ const dir = cleanupDirs.pop();
+ if (!dir) continue;
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
+ }
+ });
+
+ async function createRepo(rootDir: string): Promise {
+ const repo = path.join(rootDir, "repo");
+ await mkdir(repo, { recursive: true });
+ await git(repo, ["init"]);
+ await git(repo, ["checkout", "-b", "main"]);
+ await git(repo, ["config", "user.name", "Paperclip Test"]);
+ await git(repo, ["config", "user.email", "test@paperclip.dev"]);
+ await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
+ await git(repo, ["add", "tracked.txt"]);
+ await git(repo, ["commit", "-m", "base"]);
+ return repo;
+ }
+
+ it("creates a shallow standalone clone from the local HEAD snapshot", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-sync-"));
+ cleanupDirs.push(rootDir);
+ const repo = await createRepo(rootDir);
+ const baseHead = await git(repo, ["rev-parse", "HEAD"]);
+ await rm(path.join(repo, "tracked.txt"));
+
+ const snapshot = await readGitWorkspaceSnapshot(repo);
+ expect(snapshot).toMatchObject({
+ headCommit: baseHead,
+ branchName: "main",
+ deletedPaths: ["tracked.txt"],
+ });
+
+ await withShallowGitWorkspaceClone({
+ localDir: repo,
+ snapshot: snapshot!,
+ }, async (cloneDir) => {
+ expect((await lstat(path.join(cloneDir, ".git"))).isDirectory()).toBe(true);
+ await expect(readFile(path.join(cloneDir, ".git", "shallow"), "utf8")).resolves.toContain(baseHead);
+ expect(await git(cloneDir, ["rev-list", "--count", "HEAD"])).toBe("1");
+ expect(await git(cloneDir, ["branch", "--show-current"])).toBe("main");
+ await expect(readFile(path.join(cloneDir, "tracked.txt"), "utf8")).resolves.toBe("base\n");
+ });
+ });
+
+ it("builds thin git delta bundles relative to the imported base", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-delta-"));
+ cleanupDirs.push(rootDir);
+ const repo = await createRepo(rootDir);
+ const baseHead = await git(repo, ["rev-parse", "HEAD"]);
+ const snapshot = await readGitWorkspaceSnapshot(repo);
+ expect(snapshot).not.toBeNull();
+
+ await withShallowGitWorkspaceClone({
+ localDir: repo,
+ snapshot: snapshot!,
+ }, async (remoteDir) => {
+ const emptyBundle = path.join(rootDir, "empty.bundle");
+ await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
+ remoteDir,
+ baseSha: baseHead,
+ exportRef: createRemoteGitExportRef("test"),
+ bundlePath: emptyBundle,
+ })]);
+ expect((await stat(emptyBundle)).size).toBe(0);
+
+ await git(remoteDir, ["config", "user.name", "Paperclip Remote"]);
+ await git(remoteDir, ["config", "user.email", "remote@paperclip.dev"]);
+ await writeFile(path.join(remoteDir, "tracked.txt"), "remote\n", "utf8");
+ await git(remoteDir, ["commit", "-am", "remote update"]);
+ const remoteHead = await git(remoteDir, ["rev-parse", "HEAD"]);
+
+ const deltaBundle = path.join(rootDir, "delta.bundle");
+ const importedRef = createImportedGitRef("test");
+ const exportRef = createRemoteGitExportRef("test");
+ try {
+ await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
+ remoteDir,
+ baseSha: baseHead,
+ exportRef,
+ bundlePath: deltaBundle,
+ })]);
+ expect((await stat(deltaBundle)).size).toBeGreaterThan(0);
+
+ const importedHead = await fetchGitBundleIntoLocalRef({
+ localDir: repo,
+ bundlePath: deltaBundle,
+ exportRef,
+ importedRef,
+ baseSha: baseHead,
+ });
+ expect(importedHead).toBe(remoteHead);
+ expect(await git(repo, ["rev-list", "--count", importedRef, "--not", baseHead])).toBe("1");
+ } finally {
+ await deleteLocalGitRef({ localDir: repo, ref: importedRef });
+ }
+ });
+ });
+});
diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts
new file mode 100644
index 00000000000..1e0a37d8a1b
--- /dev/null
+++ b/packages/adapter-utils/src/git-workspace-sync.ts
@@ -0,0 +1,376 @@
+import { randomUUID } from "node:crypto";
+import { execFile } from "node:child_process";
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+export interface GitCommandResult {
+ stdout: string;
+ stderr: string;
+}
+
+export interface GitWorkspaceSnapshot {
+ headCommit: string;
+ branchName: string | null;
+ overlayPaths: string[];
+ deletedPaths: string[];
+ ignoredPaths: string[];
+}
+
+export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
+
+function shellQuote(value: string) {
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
+}
+
+export async function runLocalGit(
+ localDir: string,
+ args: string[],
+ options: {
+ timeout?: number;
+ maxBuffer?: number;
+ } = {},
+): Promise {
+ return await new Promise((resolve, reject) => {
+ execFile(
+ "git",
+ ["-C", localDir, ...args],
+ {
+ timeout: options.timeout ?? 15_000,
+ maxBuffer: options.maxBuffer ?? 1024 * 128,
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
+ return;
+ }
+ resolve({
+ stdout: stdout ?? "",
+ stderr: stderr ?? "",
+ });
+ },
+ );
+ });
+}
+
+export async function readGitWorkspaceSnapshot(localDir: string): Promise {
+ try {
+ const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ if (insideWorkTree.stdout.trim() !== "true") {
+ return null;
+ }
+
+ const [headCommitResult, branchResult, overlayDiffResult, untrackedResult, deletedResult, ignoredResult] = await Promise.all([
+ runLocalGit(localDir, ["rev-parse", "HEAD"], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ }),
+ runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ }),
+ runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], {
+ timeout: 10_000,
+ maxBuffer: 1024 * 1024,
+ }),
+ runLocalGit(localDir, ["ls-files", "--others", "--exclude-standard", "-z"], {
+ timeout: 10_000,
+ maxBuffer: 1024 * 1024,
+ }),
+ runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], {
+ timeout: 10_000,
+ maxBuffer: 256 * 1024,
+ }),
+ runLocalGit(localDir, ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], {
+ timeout: 10_000,
+ maxBuffer: 1024 * 1024,
+ }),
+ ]);
+
+ const branchName = branchResult.stdout.trim();
+ const splitNul = (value: string) => value.split("\0").map((entry) => entry.trim()).filter(Boolean);
+ return {
+ headCommit: headCommitResult.stdout.trim(),
+ branchName: branchName && branchName !== "HEAD" ? branchName : null,
+ overlayPaths: [...new Set([...splitNul(overlayDiffResult.stdout), ...splitNul(untrackedResult.stdout)])]
+ .sort((left, right) => left.localeCompare(right)),
+ deletedPaths: [...new Set(splitNul(deletedResult.stdout))]
+ .sort((left, right) => left.localeCompare(right)),
+ ignoredPaths: splitNul(ignoredResult.stdout)
+ .filter((entry) => entry.startsWith("!! "))
+ .map((entry) => entry.slice(3).replace(/\/+$/, ""))
+ .filter(Boolean)
+ .sort((left, right) => left.localeCompare(right)),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function withShallowGitWorkspaceClone(
+ input: {
+ localDir: string;
+ snapshot: GitWorkspaceSnapshot;
+ },
+ fn: (cloneDir: string) => Promise,
+): Promise {
+ const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-"));
+ const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`;
+ try {
+ await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ await runLocalGit(cloneDir, ["init"], {
+ timeout: 10_000,
+ maxBuffer: 64 * 1024,
+ });
+ await runLocalGit(cloneDir, ["fetch", "--depth=1", input.localDir, tempRef], {
+ timeout: 60_000,
+ maxBuffer: 1024 * 1024,
+ });
+ await runLocalGit(
+ cloneDir,
+ input.snapshot.branchName
+ ? ["checkout", "--force", "-B", input.snapshot.branchName, "FETCH_HEAD"]
+ : ["checkout", "--force", "--detach", "FETCH_HEAD"],
+ {
+ timeout: 60_000,
+ maxBuffer: 1024 * 1024,
+ },
+ );
+ await runLocalGit(cloneDir, ["reset", "--hard", input.snapshot.headCommit], {
+ timeout: 60_000,
+ maxBuffer: 1024 * 1024,
+ });
+ return await fn(cloneDir);
+ } finally {
+ await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ }).catch(() => undefined);
+ await fs.rm(cloneDir, { recursive: true, force: true }).catch(() => undefined);
+ }
+}
+
+export function createImportedGitRef(scope = "remote"): string {
+ return `refs/paperclip/git-sync/imported/${scope}/${randomUUID()}`;
+}
+
+export function createRemoteGitExportRef(scope = "remote"): string {
+ return `refs/paperclip/git-sync/export/${scope}/${randomUUID()}`;
+}
+
+export async function deleteLocalGitRef(input: {
+ localDir: string;
+ ref: string;
+}): Promise {
+ await runLocalGit(input.localDir, ["update-ref", "-d", input.ref], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ }).catch(() => undefined);
+}
+
+export async function fetchGitBundleIntoLocalRef(input: {
+ localDir: string;
+ bundlePath: string;
+ exportRef: string;
+ importedRef: string;
+ baseSha: string;
+}): Promise {
+ const bundleSize = (await fs.stat(input.bundlePath).catch(() => null))?.size ?? 0;
+ if (bundleSize === 0) {
+ return input.baseSha;
+ }
+
+ await runLocalGit(input.localDir, ["fetch", "--force", input.bundlePath, `${input.exportRef}:${input.importedRef}`], {
+ timeout: 60_000,
+ maxBuffer: 1024 * 1024,
+ });
+ const importedHead = await runLocalGit(input.localDir, ["rev-parse", input.importedRef], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ return importedHead.stdout.trim();
+}
+
+export function buildRemoteGitDeltaBundleScript(input: {
+ remoteDir: string;
+ baseSha: string;
+ exportRef: string;
+ bundlePath: string;
+ statusPath?: string;
+ catBundle?: boolean;
+ cleanupBundle?: boolean;
+}): string {
+ const remoteDir = shellQuote(input.remoteDir);
+ const bundlePath = shellQuote(input.bundlePath);
+ const exportRef = shellQuote(input.exportRef);
+ const baseSha = shellQuote(input.baseSha);
+ const statusPath = input.statusPath ? shellQuote(input.statusPath) : null;
+ const cleanupParts = [
+ `rm -f ${bundlePath}`,
+ ...(statusPath ? [`rm -f ${statusPath}`] : []),
+ `git -C ${remoteDir} update-ref -d ${exportRef} >/dev/null 2>&1 || true`,
+ ];
+ return [
+ "set -e",
+ input.cleanupBundle ? `cleanup() { ${cleanupParts.join("; ")}; }` : "",
+ input.cleanupBundle ? "trap cleanup EXIT" : "",
+ `mkdir -p ${shellQuote(path.posix.dirname(input.bundlePath))}`,
+ `rm -f ${bundlePath}`,
+ `git -C ${remoteDir} cat-file -e ${baseSha}^{commit}`,
+ `commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not ${baseSha})`,
+ 'if [ "$commit_count" -gt 0 ]; then',
+ ` git -C ${remoteDir} update-ref ${exportRef} HEAD`,
+ ` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} --not ${baseSha} >/dev/null`,
+ "else",
+ ` : > ${bundlePath}`,
+ "fi",
+ statusPath
+ ? [
+ `if [ -z "$(git -C ${remoteDir} status --porcelain=v1 --untracked-files=normal)" ]; then`,
+ ` printf clean > ${statusPath}`,
+ "else",
+ ` printf dirty > ${statusPath}`,
+ "fi",
+ ].join("\n")
+ : "",
+ input.catBundle ? `cat ${bundlePath}` : "",
+ ].filter(Boolean).join("\n");
+}
+
+export async function integrateImportedGitHead(input: {
+ localDir: string;
+ importedHead: string;
+}): Promise {
+ const isConcurrentRefUpdateError = (error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.includes("cannot lock ref") && message.includes("expected");
+ };
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ const snapshot = await readGitWorkspaceSnapshot(input.localDir);
+ if (!snapshot) return;
+
+ const currentHead = snapshot.headCommit;
+ if (!currentHead || currentHead === input.importedHead) return;
+
+ const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
+ const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ }).catch(() => null);
+ const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
+
+ if (mergeBaseHead === input.importedHead) {
+ return;
+ }
+
+ if (mergeBaseHead === currentHead) {
+ try {
+ await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ return;
+ } catch (error) {
+ if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
+ throw error;
+ }
+ }
+
+ let mergedTree;
+ try {
+ mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
+ timeout: 60_000,
+ maxBuffer: 256 * 1024,
+ });
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new Error(
+ `Failed to merge concurrent remote git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`,
+ );
+ }
+ const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
+ if (!mergedTreeId) {
+ throw new Error("Failed to compute a merged git tree for workspace restore.");
+ }
+
+ const mergeCommit = await runLocalGit(
+ input.localDir,
+ [
+ "commit-tree",
+ mergedTreeId,
+ "-p",
+ currentHead,
+ "-p",
+ input.importedHead,
+ "-m",
+ `Paperclip remote git sync merge ${input.importedHead.slice(0, 12)}`,
+ ],
+ {
+ timeout: 60_000,
+ maxBuffer: 64 * 1024,
+ },
+ );
+ try {
+ await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
+ timeout: 10_000,
+ maxBuffer: 16 * 1024,
+ });
+ return;
+ } catch (error) {
+ if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
+ throw error;
+ }
+ }
+
+ throw new Error(`Failed to integrate concurrent remote git history for ${input.importedHead.slice(0, 12)} after multiple retries.`);
+}
+
+export async function resetLocalGitIndexToHead(input: {
+ localDir: string;
+ checkWorkingTreeClean?: boolean;
+}): Promise {
+ try {
+ await runLocalGit(input.localDir, ["reset", "--quiet", "HEAD", "--", "."], {
+ timeout: 60_000,
+ maxBuffer: 1024 * 1024,
+ });
+ } catch (error) {
+ const detail = error && typeof error === "object"
+ ? [
+ (error as { message?: unknown }).message,
+ (error as { stderr?: unknown }).stderr,
+ (error as { stdout?: unknown }).stdout,
+ ].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n")
+ : String(error);
+ throw new Error(`Failed to reset local git index to HEAD after workspace restore: ${detail}`);
+ }
+
+ const stagedDiff = await runLocalGit(input.localDir, ["diff", "--cached", "--name-status", "HEAD", "--"], {
+ timeout: 10_000,
+ maxBuffer: 1024 * 1024,
+ });
+ if (stagedDiff.stdout.trim().length > 0) {
+ throw new Error(
+ `Workspace restore left staged git index changes after reset:\n${stagedDiff.stdout.trim()}`,
+ );
+ }
+
+ if (!input.checkWorkingTreeClean) return;
+
+ const workingTreeDiff = await runLocalGit(input.localDir, ["diff", "--name-status", "HEAD", "--"], {
+ timeout: 10_000,
+ maxBuffer: 1024 * 1024,
+ });
+ if (workingTreeDiff.stdout.trim().length > 0) {
+ console.warn(
+ "[paperclip] Workspace restore preserved local working tree changes after clean sandbox restore.",
+ );
+ }
+}
diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts
index fa5b7632d03..32e890e0d58 100644
--- a/packages/adapter-utils/src/index.ts
+++ b/packages/adapter-utils/src/index.ts
@@ -61,6 +61,18 @@ export {
redactCommandText,
} from "./command-redaction.js";
export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
+export { createRuntimeProgressReporter } from "./runtime-progress.js";
+export type {
+ RuntimeProgressSink,
+ RuntimeProgressPhase,
+ RuntimeProgressDirection,
+ RuntimeProgressTarget,
+ RuntimeProgressReporter,
+ RuntimeProgressReporterOptions,
+ RuntimeStatusPhase,
+ RuntimeStatusSink,
+ RuntimeStatusUpdate,
+} from "./runtime-progress.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts
index 900824b44c3..88bb9c53fd4 100644
--- a/packages/adapter-utils/src/remote-managed-runtime.ts
+++ b/packages/adapter-utils/src/remote-managed-runtime.ts
@@ -1,4 +1,5 @@
import path from "node:path";
+import { GIT_ARCHIVE_EXCLUDES } from "./git-workspace-sync.js";
import {
type SshRemoteExecutionSpec,
prepareWorkspaceForSshExecution,
@@ -6,6 +7,7 @@ import {
syncDirectoryToSsh,
} from "./ssh.js";
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
+import type { RuntimeProgressSink } from "./runtime-progress.js";
export interface RemoteManagedRuntimeAsset {
key: string;
@@ -20,7 +22,7 @@ export interface PreparedRemoteManagedRuntime {
workspaceRemoteDir: string;
runtimeRootDir: string;
assetDirs: Record;
- restoreWorkspace(): Promise;
+ restoreWorkspace(onProgress?: RuntimeProgressSink): Promise;
}
function asObject(value: unknown): Record {
@@ -69,6 +71,9 @@ export async function prepareRemoteManagedRuntime(input: {
workspaceLocalDir: string;
workspaceRemoteDir?: string;
assets?: RemoteManagedRuntimeAsset[];
+ // Upload progress sink. Threaded for the byte-counting transport rewrite; the
+ // child task wires it into the workspace/asset transfers.
+ onProgress?: RuntimeProgressSink;
}): Promise {
const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const workspaceRemoteDir = path.posix.join(
@@ -84,8 +89,9 @@ export async function prepareRemoteManagedRuntime(input: {
spec: input.spec,
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
+ onProgress: input.onProgress,
});
- const restoreExclude = preparedWorkspace.gitBacked ? [".git", ".paperclip-runtime"] : [".paperclip-runtime"];
+ const restoreExclude = preparedWorkspace.gitBacked ? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"] : [".paperclip-runtime"];
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: restoreExclude,
});
@@ -101,6 +107,8 @@ export async function prepareRemoteManagedRuntime(input: {
remoteDir,
followSymlinks: asset.followSymlinks,
exclude: asset.exclude,
+ onProgress: input.onProgress,
+ progressLabel: asset.key,
});
}
} catch (error) {
@@ -110,6 +118,7 @@ export async function prepareRemoteManagedRuntime(input: {
remoteDir: workspaceRemoteDir,
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
+ onProgress: input.onProgress,
});
throw error;
}
@@ -120,13 +129,14 @@ export async function prepareRemoteManagedRuntime(input: {
workspaceRemoteDir,
runtimeRootDir,
assetDirs,
- restoreWorkspace: async () => {
+ restoreWorkspace: async (onProgress?: RuntimeProgressSink) => {
await restoreWorkspaceFromSshExecution({
spec: input.spec,
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
+ onProgress,
});
},
};
diff --git a/packages/adapter-utils/src/runtime-progress.test.ts b/packages/adapter-utils/src/runtime-progress.test.ts
new file mode 100644
index 00000000000..24048404e73
--- /dev/null
+++ b/packages/adapter-utils/src/runtime-progress.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from "vitest";
+import { createRuntimeProgressReporter } from "./runtime-progress.js";
+
+const MB = 1024 * 1024;
+
+function makeClock(start = 0) {
+ let value = start;
+ return {
+ now: () => value,
+ advance: (ms: number) => {
+ value += ms;
+ },
+ };
+}
+
+describe("createRuntimeProgressReporter", () => {
+ it("formats the message with phase, label, direction, target, percent and MB", async () => {
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ });
+
+ await reporter.report(12.6 * MB, 31.4 * MB);
+
+ expect(lines).toEqual(["[paperclip] Syncing workspace to sandbox: 40% (12.6/31.4 MB)\n"]);
+ });
+
+ it("omits the label when none is provided (e.g. git history)", async () => {
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Importing git history",
+ direction: "to",
+ target: "ssh",
+ });
+
+ await reporter.report(4 * MB, 4 * MB);
+
+ expect(lines).toEqual(["[paperclip] Importing git history to ssh: 100% (4.0/4.0 MB)\n"]);
+ });
+
+ it("suppresses intermediate emits that neither cross a step nor exceed the interval", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ now: clock.now,
+ });
+
+ // First report always emits (step 0 crossed).
+ await reporter.report(1 * MB, 100 * MB); // 1%
+ // Still within the same 10% step and under 2s -> suppressed.
+ await reporter.report(2 * MB, 100 * MB); // 2%
+ await reporter.report(5 * MB, 100 * MB); // 5%
+
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toBe("[paperclip] Syncing workspace to sandbox: 1% (1.0/100.0 MB)\n");
+ });
+
+ it("emits when the percentage crosses a 10% step", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ now: clock.now,
+ });
+
+ await reporter.report(1 * MB, 100 * MB); // 1% -> emit (step 0)
+ await reporter.report(15 * MB, 100 * MB); // 15% -> crosses into step 1 -> emit
+
+ expect(lines).toHaveLength(2);
+ expect(lines[1]).toBe("[paperclip] Syncing workspace to sandbox: 15% (15.0/100.0 MB)\n");
+ });
+
+ it("emits on the time threshold even without a step crossing", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ now: clock.now,
+ });
+
+ await reporter.report(1 * MB, 100 * MB); // emit
+ await reporter.report(2 * MB, 100 * MB); // suppressed (same step, no time elapsed)
+ clock.advance(2000);
+ await reporter.report(3 * MB, 100 * MB); // 3% same step, but 2s elapsed -> emit
+
+ expect(lines).toHaveLength(2);
+ expect(lines[1]).toBe("[paperclip] Syncing workspace to sandbox: 3% (3.0/100.0 MB)\n");
+ });
+
+ it("always emits the terminal 100% line via report reaching the total", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Restoring",
+ label: "workspace",
+ direction: "from",
+ target: "sandbox",
+ now: clock.now,
+ });
+
+ await reporter.report(1 * MB, 100 * MB); // emit
+ await reporter.report(100 * MB, 100 * MB); // terminal -> always emit
+
+ expect(lines[lines.length - 1]).toBe(
+ "[paperclip] Restoring workspace from sandbox: 100% (100.0/100.0 MB)\n",
+ );
+ });
+
+ it("complete() emits the terminal 100% line even when intermediate emits were throttled", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ now: clock.now,
+ });
+
+ await reporter.report(1 * MB, 100 * MB); // emit
+ await reporter.report(5 * MB, 100 * MB); // suppressed
+ await reporter.complete();
+
+ expect(lines[lines.length - 1]).toBe(
+ "[paperclip] Syncing workspace to sandbox: 100% (100.0/100.0 MB)\n",
+ );
+ });
+
+ it("complete() is idempotent and does not double-emit after a terminal report", async () => {
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "sandbox",
+ });
+
+ await reporter.report(100 * MB, 100 * MB); // terminal
+ await reporter.complete();
+ await reporter.complete();
+
+ expect(lines).toHaveLength(1);
+ });
+
+ it("reports MB-only (no percent) when the total is unknown, plus a completion line", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Restoring",
+ label: "workspace",
+ direction: "from",
+ target: "ssh",
+ now: clock.now,
+ });
+
+ await reporter.report(2 * MB, null); // first emit
+ await reporter.report(4 * MB, null); // suppressed (no time elapsed)
+ clock.advance(2000);
+ await reporter.report(8 * MB, null); // time elapsed -> emit
+ await reporter.complete();
+
+ expect(lines).toEqual([
+ "[paperclip] Restoring workspace from ssh: 2.0 MB\n",
+ "[paperclip] Restoring workspace from ssh: 8.0 MB\n",
+ "[paperclip] Restoring workspace from ssh: 8.0 MB\n",
+ ]);
+ });
+
+ it("fail() emits a terminal failure marker with the last percent instead of a dangling line", async () => {
+ const clock = makeClock();
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "ssh",
+ now: clock.now,
+ });
+
+ await reporter.report(40 * MB, 100 * MB); // emit at 40%
+ await reporter.fail();
+
+ expect(lines).toEqual([
+ "[paperclip] Syncing workspace to ssh: 40% (40.0/100.0 MB)\n",
+ "[paperclip] Syncing workspace to ssh: failed at 40% (40.0/100.0 MB)\n",
+ ]);
+ });
+
+ it("fail() falls back to an MB marker when the total is unknown", async () => {
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Restoring",
+ direction: "from",
+ target: "ssh",
+ });
+
+ await reporter.report(3 * MB, null);
+ await reporter.fail();
+
+ expect(lines.at(-1)).toBe("[paperclip] Restoring from ssh: failed after 3.0 MB\n");
+ });
+
+ it("fail() is suppressed after a terminal completion and complete() after a failure", async () => {
+ const lines: string[] = [];
+ const reporter = createRuntimeProgressReporter({
+ sink: (line) => {
+ lines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "ssh",
+ });
+
+ await reporter.report(100 * MB, 100 * MB); // terminal complete
+ await reporter.fail(); // suppressed — already completed
+ expect(lines).toHaveLength(1);
+
+ const failLines: string[] = [];
+ const failed = createRuntimeProgressReporter({
+ sink: (line) => {
+ failLines.push(line);
+ },
+ phase: "Syncing",
+ label: "workspace",
+ direction: "to",
+ target: "ssh",
+ });
+ await failed.report(20 * MB, 100 * MB);
+ await failed.fail();
+ await failed.complete(); // suppressed — already failed
+ expect(failLines).toHaveLength(2);
+ expect(failLines.at(-1)).toContain("failed at 20%");
+ });
+});
diff --git a/packages/adapter-utils/src/runtime-progress.ts b/packages/adapter-utils/src/runtime-progress.ts
new file mode 100644
index 00000000000..11beabda637
--- /dev/null
+++ b/packages/adapter-utils/src/runtime-progress.ts
@@ -0,0 +1,167 @@
+// Shared, throttled progress reporting for execution-target sync/restore.
+//
+// Transports (sandbox / SSH) own the byte counting and call `report()` as bytes
+// move; orchestrators own the per-phase label and direction. The reporter
+// throttles emits so a long transfer doesn't flood the log: a line is emitted
+// only when the percentage crosses a step boundary (default every 10%) or once
+// at least `minIntervalMs` has elapsed since the last emit. The terminal
+// completion line is always emitted via `complete()` (or when `report()` reaches
+// the known total).
+
+/** A sink for fully-formatted progress lines (newline included). */
+export type RuntimeProgressSink = (line: string) => void | Promise;
+
+export type RuntimeProgressPhase =
+ | "Syncing"
+ | "Restoring"
+ | "Importing git history"
+ | "Exporting git history";
+
+export type RuntimeProgressDirection = "to" | "from";
+
+export type RuntimeProgressTarget = "sandbox" | "ssh";
+
+export type RuntimeStatusPhase =
+ | "git_sync"
+ | "config_sync"
+ | "adapter_startup"
+ | "restore"
+ | "export"
+ | "finalize";
+
+export interface RuntimeStatusUpdate {
+ phase: RuntimeStatusPhase;
+ message: string;
+}
+
+export type RuntimeStatusSink = (update: RuntimeStatusUpdate) => void | Promise;
+
+export interface RuntimeProgressReporterOptions {
+ sink: RuntimeProgressSink;
+ phase: RuntimeProgressPhase;
+ /** Optional per-phase label, e.g. "workspace" or an asset key. */
+ label?: string;
+ direction: RuntimeProgressDirection;
+ target: RuntimeProgressTarget;
+ /** Emit when the percentage crosses this step. Default 10. */
+ stepPercent?: number;
+ /** Emit when at least this many ms have elapsed since the last emit. Default 2000. */
+ minIntervalMs?: number;
+ /** Injectable clock for deterministic tests. Default `Date.now`. */
+ now?: () => number;
+}
+
+export interface RuntimeProgressReporter {
+ /**
+ * Report progress. Throttled: only emits on a step crossing or after
+ * `minIntervalMs`. When `totalBytes` is known and `doneBytes` reaches it, the
+ * terminal 100% line is emitted and the reporter is marked complete.
+ */
+ report(doneBytes: number, totalBytes: number | null): Promise;
+ /**
+ * Emit the terminal completion line if it hasn't been emitted yet. Idempotent.
+ */
+ complete(doneBytes?: number, totalBytes?: number | null): Promise;
+ /**
+ * Emit a terminal failure line if no terminal line has been emitted yet, so a
+ * failed transfer leaves an explicit marker instead of a dangling percentage.
+ * Idempotent and mutually exclusive with `complete()`.
+ */
+ fail(doneBytes?: number, totalBytes?: number | null): Promise