Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta
2026-03-16 07:38:08 -05:00
28 changed files with 863 additions and 37 deletions

View File

@@ -0,0 +1,201 @@
---
name: doc-maintenance
description: >
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
history to find drift — shipped features missing from docs or features
listed as upcoming that already landed. Proposes minimal edits, creates
a branch, and opens a PR. Use when asked to review docs for accuracy,
after major feature merges, or on a periodic schedule.
---
# Doc Maintenance Skill
Detect documentation drift and fix it via PR — no rewrites, no churn.
## When to Use
- Periodic doc review (e.g. weekly or after releases)
- After major feature merges
- When asked "are our docs up to date?"
- When asked to audit README / SPEC / PRODUCT accuracy
## Target Documents
| Document | Path | What matters |
|----------|------|-------------|
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
release notes. These are dev-facing or ephemeral — lower risk of user-facing
confusion.
## Workflow
### Step 1 — Detect what changed
Find the last review cursor:
```bash
# Read the last-reviewed commit SHA
CURSOR_FILE=".doc-review-cursor"
if [ -f "$CURSOR_FILE" ]; then
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
else
# First run: look back 60 days
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
fi
```
Then gather commits since the cursor:
```bash
git log "$LAST_SHA"..HEAD --oneline --no-merges
```
### Step 2 — Classify changes
Scan commit messages and changed files. Categorize into:
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
- **Structural** — new directories, config changes, new adapters, new CLI commands
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
changes, style/formatting commits. These don't affect doc accuracy.
For borderline cases, check the actual diff — a commit titled "refactor: X"
that adds a new public API is a feature.
### Step 3 — Build a change summary
Produce a concise list like:
```
Since last review (<sha>, <date>):
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
- FEATURE: Project archiving added
- BREAKING: Removed legacy webhook adapter
- STRUCTURAL: New .agents/skills/ directory convention
```
If there are no notable changes, skip to Step 7 (update cursor and exit).
### Step 4 — Audit each target doc
For each target document, read it fully and cross-reference against the change
summary. Check for:
1. **False negatives** — major shipped features not mentioned at all
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
/ "not supported" / "TBD" that already shipped
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
still correct (README only)
4. **Feature table accuracy** — does the features section reflect current
capabilities? (README only)
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
Use `references/audit-checklist.md` as the structured checklist.
Use `references/section-map.md` to know where to look for each feature area.
### Step 5 — Create branch and apply minimal edits
```bash
# Create a branch for the doc updates
BRANCH="docs/maintenance-$(date +%Y%m%d)"
git checkout -b "$BRANCH"
```
Apply **only** the edits needed to fix drift. Rules:
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
- **Preserve voice and style.** Match the existing tone of each document.
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
sections unless they're part of a factual fix.
- **No new sections.** If a feature needs a whole new section, note it in the
PR description as a follow-up — don't add it in a maintenance pass.
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
in the appropriate existing section if there isn't one already. Don't add
long descriptions.
### Step 6 — Open a PR
Commit the changes and open a PR:
```bash
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
git commit -m "docs: update documentation for accuracy
- [list each fix briefly]
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
git push -u origin "$BRANCH"
gh pr create \
--title "docs: periodic documentation accuracy update" \
--body "$(cat <<'EOF'
## Summary
Automated doc maintenance pass. Fixes documentation drift detected since
last review.
### Changes
- [list each fix]
### Change summary (since last review)
- [list notable code changes that triggered doc updates]
## Review notes
- Only factual accuracy fixes — no style/cosmetic changes
- Preserves existing voice and structure
- Larger doc additions (new sections, tutorials) noted as follow-ups
🤖 Generated by doc-maintenance skill
EOF
)"
```
### Step 7 — Update the cursor
After a successful audit (whether or not edits were needed), update the cursor:
```bash
git rev-parse HEAD > .doc-review-cursor
```
If edits were made, this is already committed in the PR branch. If no edits
were needed, commit the cursor update to the current branch.
## Change Classification Rules
| Signal | Category | Doc update needed? |
|--------|----------|-------------------|
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
| New top-level directory or config file | Structural | Maybe |
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
| `docs:` | Doc change | No (already handled) |
| Dependency bumps only | Maintenance | No |
## Patch Style Guide
- Fix the fact, not the prose
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
- If adding a feature mention, match the format of surrounding entries
(e.g. if features are in a table, add a table row)
- Keep README changes especially minimal — it shouldn't churn often
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
(e.g. change "not supported in V1" to "supported via X" rather than adding
a new section)
## Output
When the skill completes, report:
- How many commits were scanned
- How many notable changes were found
- How many doc edits were made (and to which files)
- PR link (if edits were made)
- Any follow-up items that need larger doc work

View File

@@ -0,0 +1,85 @@
# Doc Maintenance Audit Checklist
Use this checklist when auditing each target document. For each item, compare
against the change summary from git history.
## README.md
### Features table
- [ ] Each feature card reflects a shipped capability
- [ ] No feature cards for things that don't exist yet
- [ ] No major shipped features missing from the table
### Roadmap
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
- [ ] No removed/cancelled items still listed
- [ ] Items reflect current priorities (cross-check with recent PRs)
### Quickstart
- [ ] `npx paperclipai onboard` command is correct
- [ ] Manual install steps are accurate (clone URL, commands)
- [ ] Prerequisites (Node version, pnpm version) are current
- [ ] Server URL and port are correct
### "What is Paperclip" section
- [ ] High-level description is accurate
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
### "Works with" table
- [ ] All supported adapters/runtimes are listed
- [ ] No removed adapters still listed
- [ ] Logos and labels match current adapter names
### "Paperclip is right for you if"
- [ ] Use cases are still accurate
- [ ] No claims about capabilities that don't exist
### "Why Paperclip is special"
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
- [ ] No features listed that were removed or significantly changed
### FAQ
- [ ] Answers are still correct
- [ ] No references to removed features or outdated behavior
### Development section
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
- [ ] Link to DEVELOPING.md is correct
## doc/SPEC.md
### Company Model
- [ ] Fields match current schema
- [ ] Governance model description is accurate
### Agent Model
- [ ] Adapter types match what's actually supported
- [ ] Agent configuration description is accurate
- [ ] No features described as "not supported" or "not V1" that shipped
### Task Model
- [ ] Task hierarchy description is accurate
- [ ] Status values match current implementation
### Extensions / Plugins
- [ ] If plugins are shipped, no "not in V1" or "future" language
- [ ] Plugin model description matches implementation
### Open Questions
- [ ] Resolved questions removed or updated
- [ ] No "TBD" items that have been decided
## doc/PRODUCT.md
### Core Concepts
- [ ] Company, Employees, Task Management descriptions accurate
- [ ] Agent Execution modes described correctly
- [ ] No missing major concepts
### Principles
- [ ] Principles haven't been contradicted by shipped features
- [ ] No principles referencing removed capabilities
### User Flow
- [ ] Dream scenario still reflects actual onboarding
- [ ] Steps are achievable with current features

View File

@@ -0,0 +1,22 @@
# Section Map
Maps feature areas to specific document sections so the skill knows where to
look when a feature ships or changes.
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|-------------|---------------|-------------|----------------|
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
| Task Management | Features table | Task Model | Task Management |
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
| Multi-Company | Features table, FAQ | Company Model | Company |
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
| CLI Commands | Development section | — | — |
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
| Skills / Skill Injection | "Why special" | — | — |
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
| Mobile / UI | Features table | — | — |
| Project Archiving | — | — | — |
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |

6
.gitignore vendored
View File

@@ -38,6 +38,10 @@ tmp/
.claude/settings.local.json
.paperclip-local/
# Doc maintenance cursor
.doc-review-cursor
# Playwright
tests/e2e/test-results/
tests/e2e/playwright-report/
tests/e2e/playwright-report/
.superset/

View File

@@ -239,7 +239,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ⚪ ClipMart - buy and sell entire agent companies
- ⚪ Easy agent configurations / easier to understand
- ⚪ Better support for harness engineering
- Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
- ⚪ Better docs
<br/>

View File

@@ -89,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
## Docker For Untrusted PR Review
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
## Database in Dev (Auto-Handled)
For local development, leave `DATABASE_URL` unset.

View File

@@ -93,6 +93,12 @@ Notes:
- Without API keys, the app still runs normally.
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
## Untrusted PR Review Container
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
## Onboard Smoke Test (Ubuntu + npm only)
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:

View File

@@ -188,12 +188,15 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
| Adapter | Mechanism | Example |
| --------- | ----------------------- | --------------------------------------------- |
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
| Adapter | Mechanism | Example |
| -------------------- | ----------------------- | --------------------------------------------- |
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
| `hermes_local` | Hermes agent process | Local Hermes agent |
The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture).
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
### Adapter Interface
@@ -429,7 +432,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
- Plugin-registrable UI components (future)
This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible.
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.
---

135
doc/UNTRUSTED-PR-REVIEW.md Normal file
View File

@@ -0,0 +1,135 @@
# Untrusted PR Review In Docker
Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly.
This is intentionally separate from the normal Paperclip dev image.
## What this container isolates
- `codex` auth/session state in a Docker volume, not your host `~/.codex`
- `claude` auth/session state in a Docker volume, not your host `~/.claude`
- `gh` auth state in the same container-local home volume
- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work`
By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent.
## Files
- `docker/untrusted-review/Dockerfile`
- `docker-compose.untrusted-review.yml`
- `review-checkout-pr` inside the container
## Build and start a shell
```sh
docker compose -f docker-compose.untrusted-review.yml build
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
```
That opens an interactive shell in the review container with:
- Node + Corepack/pnpm
- `codex`
- `claude`
- `gh`
- `git`, `rg`, `fd`, `jq`
## First-time login inside the container
Run these once. The resulting login state persists in the `review-home` Docker volume.
```sh
gh auth login
codex login
claude login
```
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
```sh
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
```
## Check out a PR safely
Inside the container:
```sh
review-checkout-pr paperclipai/paperclip 432
cd /work/checkouts/paperclipai-paperclip/pr-432
```
What this does:
1. Creates or reuses a repo clone under `/work/repos/...`
2. Fetches `pull/<pr>/head` from GitHub
3. Creates a detached git worktree under `/work/checkouts/...`
The checkout lives entirely inside the container volume.
## Ask Codex or Claude to review it
Inside the PR checkout:
```sh
codex
```
Then give it a prompt like:
```text
Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references.
```
Or with Claude:
```sh
claude
```
## Preview the Paperclip app from the PR
Only do this when you intentionally want to execute the PR's code inside the container.
Inside the PR checkout:
```sh
pnpm install
HOST=0.0.0.0 pnpm dev
```
Open from the host:
- `http://localhost:3100`
The Compose file also exposes Vite's default port:
- `http://localhost:5173`
Notes:
- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host.
- If you only want static inspection, do not run install/dev commands.
- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`.
## Reset state
Remove the review container volumes when you want a clean environment:
```sh
docker compose -f docker-compose.untrusted-review.yml down -v
```
That deletes:
- Codex/Claude/GitHub login state stored in `review-home`
- cloned repos, worktrees, installs, and scratch data stored in `review-work`
## Security limits
This is a useful isolation boundary, but it is still Docker, not a full VM.
- A reviewed PR can still access the container's network unless you disable it.
- Any secrets you pass into the container are available to code you execute inside it.
- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary.
- If you need a stronger boundary than this, use a disposable VM instead of Docker.

View File

@@ -0,0 +1,33 @@
services:
review:
build:
context: .
dockerfile: docker/untrusted-review/Dockerfile
init: true
tty: true
stdin_open: true
working_dir: /work
environment:
HOME: "/home/reviewer"
CODEX_HOME: "/home/reviewer/.codex"
CLAUDE_HOME: "/home/reviewer/.claude"
PAPERCLIP_HOME: "/home/reviewer/.paperclip-review"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
ports:
- "${REVIEW_PAPERCLIP_PORT:-3100}:3100"
- "${REVIEW_VITE_PORT:-5173}:5173"
volumes:
- review-home:/home/reviewer
- review-work:/work
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:mode=1777,size=1g
volumes:
review-home:
review-work:

View File

@@ -0,0 +1,44 @@
FROM node:lts-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
fd-find \
gh \
git \
jq \
less \
openssh-client \
procps \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
RUN corepack enable \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
RUN useradd --create-home --shell /bin/bash reviewer
ENV HOME=/home/reviewer \
CODEX_HOME=/home/reviewer/.codex \
CLAUDE_HOME=/home/reviewer/.claude \
PAPERCLIP_HOME=/home/reviewer/.paperclip-review \
PNPM_HOME=/home/reviewer/.local/share/pnpm \
PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WORKDIR /work
COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr
RUN chmod +x /usr/local/bin/review-checkout-pr \
&& mkdir -p /work \
&& chown -R reviewer:reviewer /work
USER reviewer
EXPOSE 3100 5173
CMD ["bash", "-l"]

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: review-checkout-pr <owner/repo|github-url> <pr-number> [checkout-dir]
Examples:
review-checkout-pr paperclipai/paperclip 432
review-checkout-pr https://github.com/paperclipai/paperclip.git 432
EOF
}
if [[ $# -lt 2 || $# -gt 3 ]]; then
usage >&2
exit 1
fi
normalize_repo_slug() {
local raw="$1"
raw="${raw#git@github.com:}"
raw="${raw#ssh://git@github.com/}"
raw="${raw#https://github.com/}"
raw="${raw#http://github.com/}"
raw="${raw%.git}"
printf '%s\n' "${raw#/}"
}
repo_slug="$(normalize_repo_slug "$1")"
pr_number="$2"
if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then
echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2
exit 1
fi
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
echo "PR number must be numeric, got: $pr_number" >&2
exit 1
fi
repo_key="${repo_slug//\//-}"
mirror_dir="/work/repos/${repo_key}"
checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}"
pr_ref="refs/remotes/origin/pr/${pr_number}"
mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")"
if [[ ! -d "$mirror_dir/.git" ]]; then
if command -v gh >/dev/null 2>&1; then
gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none
else
git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir"
fi
fi
git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}"
if [[ -e "$checkout_dir" ]]; then
printf '%s\n' "$checkout_dir"
exit 0
fi
git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null
printf '%s\n' "$checkout_dir"

View File

@@ -103,9 +103,10 @@ export interface HostServices {
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
};
/** Provides `events.emit`. */
/** Provides `events.emit` and `events.subscribe`. */
events: {
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
subscribe(params: WorkerToHostMethods["events.subscribe"][0]): Promise<void>;
};
/** Provides `http.fetch`. */
@@ -261,6 +262,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Events
"events.emit": "events.emit",
"events.subscribe": "events.subscribe",
// HTTP
"http.fetch": "http.outbound",
@@ -407,6 +409,9 @@ export function createHostClientHandlers(
"events.emit": gated("events.emit", async (params) => {
return services.events.emit(params);
}),
"events.subscribe": gated("events.subscribe", async (params) => {
return services.events.subscribe(params);
}),
// HTTP
"http.fetch": gated("http.fetch", async (params) => {

View File

@@ -482,6 +482,10 @@ export interface WorkerToHostMethods {
params: { name: string; companyId: string; payload: unknown },
result: void,
];
"events.subscribe": [
params: { eventPattern: string; filter?: Record<string, unknown> | null },
result: void,
];
// HTTP
"http.fetch": [

View File

@@ -19,8 +19,7 @@
* |--- request(initialize) -------------> | → calls plugin.setup(ctx)
* |<-- response(ok:true) ---------------- |
* | |
* |--- request(onEvent) ----------------> | → dispatches to registered handler
* |<-- response(void) ------------------ |
* |--- notification(onEvent) -----------> | → dispatches to registered handler
* | |
* |<-- request(state.get) --------------- | ← SDK client call from plugin code
* |--- response(result) ----------------> |
@@ -387,6 +386,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
registration = { name, filter: filterOrFn, fn: maybeFn };
}
eventHandlers.push(registration);
// Register subscription on the host so events are forwarded to this worker
void callHost("events.subscribe", { eventPattern: name, filter: registration.filter ?? null }).catch((err) => {
notifyHost("log", {
level: "warn",
message: `Failed to subscribe to event "${name}" on host: ${err instanceof Error ? err.message : String(err)}`,
});
});
return () => {
const idx = eventHandlers.indexOf(registration);
if (idx !== -1) eventHandlers.splice(idx, 1);
@@ -1107,6 +1113,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
handleOnEvent(notif.params as OnEventParams).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
});
});
}
}
}

View File

@@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [
"pi_local",
"cursor",
"openclaw_gateway",
"hermes_local",
] as const;
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];

View File

@@ -41,6 +41,7 @@
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"hermes-paperclip-adapter": "0.1.1",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/plugin-sdk": "workspace:*",

View File

@@ -63,6 +63,15 @@ import {
import {
agentConfigurationDoc as piAgentConfigurationDoc,
} from "@paperclipai/adapter-pi-local";
import {
execute as hermesExecute,
testEnvironment as hermesTestEnvironment,
sessionCodec as hermesSessionCodec,
} from "hermes-paperclip-adapter/server";
import {
agentConfigurationDoc as hermesAgentConfigurationDoc,
models as hermesModels,
} from "hermes-paperclip-adapter";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@@ -151,6 +160,16 @@ const piLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: piAgentConfigurationDoc,
};
const hermesLocalAdapter: ServerAdapterModule = {
type: "hermes_local",
execute: hermesExecute,
testEnvironment: hermesTestEnvironment,
sessionCodec: hermesSessionCodec,
models: hermesModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: hermesAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[
claudeLocalAdapter,
@@ -160,6 +179,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
cursorLocalAdapter,
geminiLocalAdapter,
openclawGatewayAdapter,
hermesLocalAdapter,
processAdapter,
httpAdapter,
].map((a) => [a.type, a]),

View File

@@ -556,6 +556,18 @@ export function buildHostServices(
}
await scopedBus.emit(params.name, params.companyId, params.payload);
},
async subscribe(params: { eventPattern: string; filter?: Record<string, unknown> | null }) {
const handler = async (event: import("@paperclipai/plugin-sdk").PluginEvent) => {
if (notifyWorker) {
notifyWorker("onEvent", { event });
}
};
if (params.filter) {
scopedBus.subscribe(params.eventPattern as any, params.filter as any, handler);
} else {
scopedBus.subscribe(params.eventPattern as any, handler);
}
},
},
http: {
@@ -1060,6 +1072,10 @@ export function buildHostServices(
dispose() {
disposed = true;
// Clear event bus subscriptions to prevent accumulation on worker restart.
// Without this, each crash/restart cycle adds duplicate subscriptions.
scopedBus.clear();
// Snapshot to avoid iterator invalidation from concurrent sendMessage() calls
const snapshot = Array.from(activeSubscriptions);
activeSubscriptions.clear();

View File

@@ -75,11 +75,15 @@ export function CommandPalette() {
enabled: !!selectedCompanyId && open,
});
const { data: projects = [] } = useQuery({
const { data: allProjects = [] } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const projects = useMemo(
() => allProjects.filter((p) => !p.archivedAt),
[allProjects],
);
function go(path: string) {
setOpen(false);

View File

@@ -131,8 +131,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId,
});
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
[projects, issue.projectId],
);
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
projects: activeProjects,
companyId,
userId: currentUserId,
});

View File

@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
return (
<div
className={cn(
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
theme === "dark" && "prose-invert",
className,
)}

View File

@@ -288,8 +288,12 @@ export function NewIssueDialog() {
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt),
[projects],
);
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
projects: activeProjects,
companyId: effectiveCompanyId,
userId: currentUserId,
});

View File

@@ -13,7 +13,7 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
@@ -34,6 +34,8 @@ interface ProjectPropertiesProps {
onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
onArchive?: (archived: boolean) => void;
archivePending?: boolean;
}
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
@@ -152,7 +154,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
);
}
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false);
@@ -954,6 +956,45 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)}
</div>
{onArchive && (
<>
<Separator className="my-4" />
<div className="space-y-4 py-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone
</div>
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{project.archivedAt
? "Unarchive this project to restore it in the sidebar and project selectors."
: "Archive this project to hide it from the sidebar and project selectors."}
</p>
<Button
size="sm"
variant="destructive"
disabled={archivePending}
onClick={() => {
const action = project.archivedAt ? "Unarchive" : "Archive";
const confirmed = window.confirm(
`${action} project "${project.name}"?`,
);
if (!confirmed) return;
onArchive(!project.archivedAt);
}}
>
{archivePending ? (
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
) : project.archivedAt ? (
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
) : (
<><Archive className="h-3 w-3 mr-1" />Archive project</>
)}
</Button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -178,12 +178,13 @@
background: oklch(0.5 0 0);
}
/* Auto-hide scrollbar: fully transparent by default, visible on container hover */
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
.scrollbar-auto-hide::-webkit-scrollbar-track {
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
background: transparent !important;
transition: background 150ms ease;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important;
@@ -411,30 +412,118 @@
.paperclip-mdxeditor-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.84em;
font-size: 0.78em;
}
.paperclip-mdxeditor-content pre {
margin: 0.5rem 0;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border);
margin: 0.4rem 0;
padding: 0;
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: calc(var(--radius) - 3px);
background: color-mix(in oklab, var(--accent) 50%, transparent);
background: #1e1e2e;
color: #cdd6f4;
overflow-x: auto;
}
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
Matches the editor theme so rendered code looks consistent. */
.prose pre {
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 3px);
background-color: color-mix(in oklab, var(--accent) 50%, transparent);
color: var(--foreground);
/* Dark theme for CodeMirror code blocks inside the MDXEditor.
Overrides the default cm6-theme-basic-light that MDXEditor bundles. */
.paperclip-mdxeditor .cm-editor {
background-color: #1e1e2e !important;
color: #cdd6f4 !important;
font-size: 0.78em;
}
.prose code {
.paperclip-mdxeditor .cm-gutters {
background-color: #181825 !important;
color: #585b70 !important;
border-right: 1px solid #313244 !important;
}
.paperclip-mdxeditor .cm-activeLineGutter {
background-color: #1e1e2e !important;
}
.paperclip-mdxeditor .cm-activeLine {
background-color: color-mix(in oklab, #cdd6f4 5%, transparent) !important;
}
.paperclip-mdxeditor .cm-cursor,
.paperclip-mdxeditor .cm-dropCursor {
border-left-color: #cdd6f4 !important;
}
.paperclip-mdxeditor .cm-selectionBackground {
background-color: color-mix(in oklab, #89b4fa 25%, transparent) !important;
}
.paperclip-mdxeditor .cm-focused .cm-selectionBackground {
background-color: color-mix(in oklab, #89b4fa 30%, transparent) !important;
}
.paperclip-mdxeditor .cm-content {
caret-color: #cdd6f4;
}
/* MDXEditor code block language selector show on hover only */
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"] {
position: relative;
}
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"],
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 2;
opacity: 0;
transition: opacity 150ms ease;
}
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"] select,
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] select {
background-color: #313244;
color: #cdd6f4;
border-color: #45475a;
}
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeBlockToolbar_"],
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeBlockToolbar_"] {
opacity: 1;
}
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
Dark theme code blocks with compact sizing.
Override prose CSS variables so prose-invert can't revert to defaults. */
.paperclip-markdown {
--tw-prose-pre-bg: #1e1e2e;
--tw-prose-pre-code: #cdd6f4;
--tw-prose-invert-pre-bg: #1e1e2e;
--tw-prose-invert-pre-code: #cdd6f4;
}
.paperclip-markdown pre {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent) !important;
border-radius: calc(var(--radius) - 3px) !important;
background-color: #1e1e2e !important;
color: #cdd6f4 !important;
padding: 0.5rem 0.65rem !important;
margin: 0.4rem 0 !important;
font-size: 0.78em !important;
overflow-x: auto;
white-space: pre;
}
.paperclip-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.84em;
font-size: 0.78em;
}
.paperclip-markdown pre code {
font-size: inherit;
color: inherit;
background: none;
}
/* Remove backtick pseudo-elements from inline code (prose default adds them) */

View File

@@ -285,7 +285,7 @@ export function OrgChart() {
</div>
<div
ref={containerRef}
className="w-full h-[calc(100vh-7rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{ cursor: dragging ? "grabbing" : "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}

View File

@@ -274,6 +274,21 @@ export function ProjectDetail() {
onSuccess: invalidateProject,
});
const archiveProject = useMutation({
mutationFn: (archived: boolean) =>
projectsApi.update(
projectLookupRef,
{ archivedAt: archived ? new Date().toISOString() : null },
resolvedCompanyId ?? lookupCompanyId,
),
onSuccess: (_, archived) => {
invalidateProject();
if (archived) {
navigate("/projects");
}
},
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
if (!resolvedCompanyId) throw new Error("No company selected");
@@ -476,6 +491,8 @@ export function ProjectDetail() {
onUpdate={(data) => updateProject.mutate(data)}
onFieldUpdate={updateProjectField}
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
onArchive={(archived) => archiveProject.mutate(archived)}
archivePending={archiveProject.isPending}
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -22,11 +22,15 @@ export function Projects() {
setBreadcrumbs([{ label: "Projects" }]);
}, [setBreadcrumbs]);
const { data: projects, isLoading, error } = useQuery({
const { data: allProjects, isLoading, error } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const projects = useMemo(
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
@@ -47,7 +51,7 @@ export function Projects() {
{error && <p className="text-sm text-destructive">{error.message}</p>}
{projects && projects.length === 0 && (
{!isLoading && projects.length === 0 && (
<EmptyState
icon={Hexagon}
message="No projects yet."
@@ -56,7 +60,7 @@ export function Projects() {
/>
)}
{projects && projects.length > 0 && (
{projects.length > 0 && (
<div className="border border-border">
{projects.map((project) => (
<EntityRow