Compare commits

..

No commits in common. "712ccc802f4e159a0636ff5057eb1741be47506b" and "bd5c988728997567c7c55f9c19c7dd25479aa622" have entirely different histories.

39 changed files with 169 additions and 1047 deletions

View file

@ -63,7 +63,7 @@ async function startTempDatabase() {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -756,7 +756,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -51,9 +51,10 @@ Public packages are discovered from:
- `packages/`
- `server/`
- `ui/`
- `cli/`
`ui/` is ignored because it is private.
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
- finds all public packages
@ -64,18 +65,6 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
Those rewrites are temporary. The working tree is restored after publish or dry-run.
## `@paperclipai/ui` packaging
The UI package publishes prebuilt static assets, not the source workspace.
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
- keeps the release-managed `name` and `version`
- publishes only `dist/`
- omits the source-only dependency graph from downstream installs
After packing or publishing, `postpack` restores the development manifest automatically.
## Version formats
Paperclip uses calendar versions:
@ -146,7 +135,6 @@ This is the fastest way to restore the default install path if a stable release
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

View file

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

View file

@ -186,21 +186,17 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
### Execution Adapters
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
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}` |
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
| 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 generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
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
@ -380,7 +376,7 @@ Flow:
| Layer | Technology |
| -------- | ------------------------------------------------------------ |
| Frontend | React + Vite |
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
| Auth | [Better Auth](https://www.better-auth.com/) |
@ -410,7 +406,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
### Work Artifacts
Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain.
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
### Open Questions
@ -480,14 +476,15 @@ Each is a distinct page/route:
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
- [ ] **Default CEO** — strategic planning, delegation, board communication
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
- [ ] **REST API** — full API for agent interaction (Express)
- [ ] **REST API** — full API for agent interaction (Hono)
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
- [ ] **Agent auth** — connection string generation with URL + key + instructions
- [ ] **One-command dev setup** — embedded PGlite, everything local
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
### Not V1
- Template export/import
- Knowledge base - a future plugin
- Advanced governance models (hiring budgets, multi-member boards)
- Revenue/expense tracking beyond token costs - a future plugin

View file

@ -35,8 +35,8 @@
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
@ -44,10 +44,5 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@9.15.4",
"pnpm": {
"patchedDependencies": {
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
}
}
"packageManager": "pnpm@9.15.4"
}

View file

@ -352,6 +352,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const combinedPath = path.join(skillsDir, "agent-instructions.md");
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
effectiveInstructionsFilePath = combinedPath;
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -415,6 +415,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -307,6 +307,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -253,6 +253,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -221,6 +221,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(

View file

@ -266,6 +266,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
await onLog(
"stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);

View file

@ -67,7 +67,7 @@ async function createTempDatabase(): Promise<string> {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -150,7 +150,7 @@ async function ensureEmbeddedPostgresConnection(
password: "paperclip",
port: selectedPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -1,22 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index ccfe17a82f4879bf20cc345c579a987d9eba5309..dd689f5908f625f49b4785318daea736aa88927f 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -133,7 +133,7 @@ class EmbeddedPostgres {
`--pwfile=${passwordFile}`,
`--lc-messages=${LC_MESSAGES_LOCALE}`,
...this.options.initdbFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
@@ -177,7 +177,7 @@ class EmbeddedPostgres {
'-p',
this.options.port.toString(),
...this.options.postgresFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it

69
pnpm-lock.yaml generated
View file

@ -4,11 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
embedded-postgres@18.1.0-beta.16:
hash: qmixl47dgryk2bbwt4egonhgem
path: patches/embedded-postgres@18.1.0-beta.16.patch
importers:
.:
@ -27,7 +22,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
cli:
dependencies:
@ -78,7 +73,7 @@ importers:
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
version: 18.1.0-beta.16
picocolors:
specifier: ^1.1.1
version: 1.1.1
@ -230,7 +225,7 @@ importers:
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
version: 18.1.0-beta.16
postgres:
specifier: ^3.4.5
version: 3.4.8
@ -249,7 +244,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/create-paperclip-plugin:
dependencies:
@ -299,7 +294,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/examples/plugin-file-browser-example:
dependencies:
@ -499,7 +494,7 @@ importers:
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
version: 18.1.0-beta.16
express:
specifier: ^5.1.0
version: 5.2.1
@ -575,7 +570,7 @@ importers:
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
ui:
dependencies:
@ -696,7 +691,7 @@ importers:
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
packages:
@ -9978,7 +9973,7 @@ snapshots:
electron-to-chromium@1.5.286: {}
embedded-postgres@18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem):
embedded-postgres@18.1.0-beta.16:
dependencies:
async-exit-hook: 2.0.1
pg: 8.18.0
@ -12174,52 +12169,8 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.0
jsdom: 28.1.0(@noble/hashes@2.0.1)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4

View file

@ -1,77 +0,0 @@
# v2026.325.0
> Released: 2026-03-25
## Highlights
- **Company import/export** — Full company portability with a file-browser UX for importing and exporting agent companies. Includes rich frontmatter preview, nested file picker, merge-history support, GitHub shorthand refs, and CLI `company import`/`company export` commands. Imported companies open automatically after import, and heartbeat timers are disabled for imported agents by default. ([#840](https://github.com/paperclipai/paperclip/pull/840), [#1631](https://github.com/paperclipai/paperclip/pull/1631), [#1632](https://github.com/paperclipai/paperclip/pull/1632), [#1655](https://github.com/paperclipai/paperclip/pull/1655))
- **Company skills library** — New company-scoped skills system with a skills UI, agent skill sync across all local adapters (Claude, Codex, Pi, Gemini), pinned GitHub skills with update checks, and built-in skill support. ([#1346](https://github.com/paperclipai/paperclip/pull/1346))
- **Routines and recurring tasks** — Full routines engine with triggers, routine runs, coalescing, and recurring task portability. Includes API documentation and routine export support. ([#1351](https://github.com/paperclipai/paperclip/pull/1351), [#1622](https://github.com/paperclipai/paperclip/pull/1622), @aronprins)
## Improvements
- **Inline join requests in inbox** — Join requests now render inline in the inbox alongside approvals and other work items.
- **Onboarding seeding** — New projects and issues are seeded with goal context during onboarding for a better first-run experience.
- **Agent instructions recovery** — Managed agent instructions are recovered from disk on startup; instructions are preserved across adapter switches.
- **Heartbeats settings page** — Shows all agents regardless of interval config; added a "Disable All" button for quick bulk control.
- **Agent history via participation** — Agent issue history now uses participation records instead of direct assignment lookups.
- **Alphabetical agent sorting** — Agents are sorted alphabetically by name across all views.
- **Company org chart assets** — Improved generated org chart visuals for companies.
- **Improved CLI API connection errors** — Better error messages when the CLI cannot reach the Paperclip API.
- **Markdown mention links** — Custom URL schemes are now allowed in Lexical LinkNode, enabling mention pills with proper linking behavior. Atomic deletion of mention pills works correctly.
- **Issue workspace reuse** — Workspaces are correctly reused after isolation runs.
- **Failed-run session resume** — Explicit failed-run sessions can now be resumed via honor flag.
- **Docker image CI** — Added Docker image build and deploy workflow. ([#542](https://github.com/paperclipai/paperclip/pull/542), @albttx)
- **Project filter on issues** — Issues list can now be filtered by project. ([#552](https://github.com/paperclipai/paperclip/pull/552), @mvanhorn)
- **Inline comment image attachments** — Uploaded images are now embedded inline in comments. ([#551](https://github.com/paperclipai/paperclip/pull/551), @mvanhorn)
- **AGENTS.md fallback** — Claude-local adapter gracefully falls back when AGENTS.md is missing. ([#550](https://github.com/paperclipai/paperclip/pull/550), @mvanhorn)
- **Company-creator skill** — New skill for scaffolding agent company packages from scratch.
- **Reports page rename** — Reports section renamed for clarity. ([#1380](https://github.com/paperclipai/paperclip/pull/1380), @DanielSousa)
- **Eval framework bootstrap** — Promptfoo-based evaluation framework with YAML test cases for systematic agent behavior testing. ([#832](https://github.com/paperclipai/paperclip/pull/832), @mvanhorn)
- **Board CLI authentication** — Browser-based auth flow for the CLI so board users can authenticate without manually copying API keys. ([#1635](https://github.com/paperclipai/paperclip/pull/1635))
## Fixes
- **Embedded Postgres initdb in Docker slim** — Fixed initdb failure in slim containers by adding proper initdbFlags types. ([#737](https://github.com/paperclipai/paperclip/pull/737), @alaa-alghazouli)
- **OpenClaw gateway crash** — Fixed unhandled rejection when challengePromise fails. ([#743](https://github.com/paperclipai/paperclip/pull/743), @Sigmabrogz)
- **Agent mention pill alignment** — Fixed vertical misalignment between agent mention pills and project mention pills.
- **Task assignment grants** — Preserved task assignment grants for agents that have already joined.
- **Instructions tab state** — Fixed tab state not updating correctly when switching between agents.
- **Imported agent bundle frontmatter** — Fixed frontmatter leakage in imported agent bundles.
- **Login form 1Password detection** — Fixed login form not being detected by password managers; Enter key now submits correctly. ([#1014](https://github.com/paperclipai/paperclip/pull/1014))
- **Pill contrast (WCAG)** — Improved mention pill contrast using WCAG contrast ratios on composited backgrounds.
- **Documents horizontal scroll** — Prevented documents row from causing horizontal scroll on mobile.
- **Toggle switch sizing** — Fixed oversized toggle switches on mobile; added missing `data-slot` attributes.
- **Agent instructions tab responsive** — Made agent instructions tab responsive on mobile.
- **Monospace font sizing** — Adjusted inline code font size and added dark mode background.
- **Priority icon removal** — Removed priority icon from issue rows for a cleaner list view.
- **Same-page issue toasts** — Suppressed redundant toasts when navigating to an issue already on screen.
- **Noisy adapter log** — Removed noisy "Loaded agent instructions file" log message from all adapters.
- **Pi local adapter** — Fixed Pi adapter missing from `isLocal` check. ([#1382](https://github.com/paperclipai/paperclip/pull/1382), @lucas-stellet)
- **CLI auth migration idempotency** — Made migration 0044 idempotent to avoid failures on re-run.
- **Dev restart tracking**`.paperclip` and test-only paths are now ignored in dev restart detection.
- **Duplicate CLI auth flag** — Fixed duplicate `--company` flag on `auth login`.
- **Gemini local execution** — Fixed Gemini local adapter execution and diagnostics.
- **Sidebar ordering** — Preserved sidebar ordering during company portability operations.
- **Company skill deduplication** — Fixed duplicate skill inventory refreshes.
- **Worktree merge-history migrations** — Fixed migration handling in worktree contexts. ([#1385](https://github.com/paperclipai/paperclip/pull/1385))
## Upgrade Guide
Seven new database migrations (`0038``0044`) will run automatically on startup:
- **Migration 0038** adds process tracking columns to heartbeat runs (PID, started-at, retry tracking).
- **Migration 0039** adds the routines engine tables (routines, triggers, routine runs).
- **Migrations 00400042** extend company skills, recurring tasks, and portability metadata.
- **Migration 0043** adds the Codex managed-home and agent instructions recovery columns.
- **Migration 0044** adds board API keys and CLI auth challenge tables for browser-based CLI auth.
All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
If you use the company import/export feature, note that imported companies have heartbeat timers disabled by default. Re-enable them manually from the Heartbeats settings page after verifying adapter configuration.
## Contributors
Thank you to everyone who contributed to this release!
@alaa-alghazouli, @albttx, @AOrobator, @aronprins, @cryppadotta, @DanielSousa, @lucas-stellet, @mvanhorn, @richardanaya, @Sigmabrogz

View file

@ -1,31 +0,0 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const uiDir = join(repoRoot, "ui");
const packageJsonPath = join(uiDir, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const publishPackageJson = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
license: packageJson.license,
homepage: packageJson.homepage,
bugs: packageJson.bugs,
repository: packageJson.repository,
type: packageJson.type,
files: ["dist"],
publishConfig: {
access: "public",
},
};
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
console.log(" ✓ Generated publishable UI package.json");

View file

@ -72,7 +72,7 @@ async function startTempDatabase() {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
describe("agentJoinGrantsFromDefaults", () => {
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
expect(agentJoinGrantsFromDefaults(null)).toEqual([
{
permissionKey: "tasks:assign",
scope: null,
},
]);
});
it("preserves invite agent grants and appends tasks:assign", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "agents:create",
scope: null,
},
],
},
}),
).toEqual([
{
permissionKey: "agents:create",
scope: null,
},
{
permissionKey: "tasks:assign",
scope: null,
},
]);
});
it("does not duplicate tasks:assign when invite defaults already include it", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
],
},
}),
).toEqual([
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
]);
});
});

View file

@ -20,29 +20,16 @@ describe("issue goal fallback", () => {
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
projectGoalId: "goal-3",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("inherits the project goal when creating a project-linked issue", () => {
it("does not force a company goal when the issue belongs to a project", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
projectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the project has no goal", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
projectGoalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
@ -53,47 +40,20 @@ describe("issue goal fallback", () => {
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
currentProjectGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("switches from the company fallback to the project goal when a project is added later", () => {
it("clears the fallback when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
currentProjectGoalId: null,
projectId: "project-1",
goalId: null,
projectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("backfills the project goal for legacy project-linked issues on update", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: "project-1",
currentGoalId: null,
currentProjectGoalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("preserves an explicit goal across project fallback changes", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: "project-1",
currentGoalId: "goal-explicit",
currentProjectGoalId: "goal-2",
projectId: "project-2",
projectGoalId: "goal-3",
defaultGoalId: "goal-1",
}),
).toBe("goal-explicit");
).toBeNull();
});
});

View file

@ -1,187 +0,0 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getAncestors: vi.fn(),
findMentionedProjectIds: vi.fn(),
getCommentCursor: vi.fn(),
getComment: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
listByIds: vi.fn(),
}));
const mockGoalService = vi.hoisted(() => ({
getById: vi.fn(),
getDefaultCompanyGoal: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
const legacyProjectLinkedIssue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-581",
title: "Legacy onboarding task",
description: "Seed the first CEO task",
status: "todo",
priority: "medium",
projectId: "22222222-2222-4222-8222-222222222222",
goalId: null,
parentId: null,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
updatedAt: new Date("2026-03-24T12:00:00Z"),
executionWorkspaceId: null,
labels: [],
labelIds: [],
};
const projectGoal = {
id: "44444444-4444-4444-8444-444444444444",
companyId: "company-1",
title: "Launch the company",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
};
describe("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
mockIssueService.getCommentCursor.mockResolvedValue({
totalComments: 0,
latestCommentId: null,
latestCommentAt: null,
});
mockIssueService.getComment.mockResolvedValue(null);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
urlKey: "onboarding",
goalId: projectGoal.id,
goalIds: [projectGoal.id],
goals: [{ id: projectGoal.id, title: projectGoal.title }],
name: "Onboarding",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/company-1/project-1",
effectiveLocalFolder: "/tmp/company-1/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
});
mockProjectService.listByIds.mockResolvedValue([]);
mockGoalService.getById.mockImplementation(async (id: string) =>
id === projectGoal.id ? projectGoal : null,
);
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
});
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
expect(res.status).toBe(200);
expect(res.body.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
const res = await request(createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(res.body.issue.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
});

View file

@ -68,7 +68,7 @@ async function startTempDatabase() {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -130,7 +130,7 @@ async function startTempDatabase() {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -76,7 +76,7 @@ async function startTempDatabase() {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View file

@ -347,7 +347,7 @@ export async function startServer(): Promise<StartedServer> {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: appendEmbeddedPostgresLog,
onError: appendEmbeddedPostgresLog,
});

View file

@ -1411,25 +1411,6 @@ function grantsFromDefaults(
return result;
}
export function agentJoinGrantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
const grants = grantsFromDefaults(defaultsPayload, "agent");
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
return grants;
}
return [
...grants,
{
permissionKey: "tasks:assign",
scope: null
}
];
}
type JoinRequestManagerCandidate = {
id: string;
role: string;
@ -2637,8 +2618,17 @@ export function accessRoutes(
"member",
"active"
);
const grants = agentJoinGrantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null
await access.setPrincipalPermission(
companyId,
"agent",
created.id,
"tasks:assign",
true,
req.actor.userId ?? null
);
const grants = grantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null,
"agent"
);
await access.setPrincipalGrants(
companyId,

View file

@ -171,33 +171,6 @@ export function issueRoutes(db: Db, storage: StorageService) {
return rawId;
}
async function resolveIssueProjectAndGoal(issue: {
companyId: string;
projectId: string | null;
goalId: string | null;
}) {
const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null);
const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null);
const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]);
if (directGoal) {
return { project, goal: directGoal };
}
const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null;
if (projectGoalId) {
const projectGoal = await goalsSvc.getById(projectGoalId);
return { project, goal: projectGoal };
}
if (!issue.projectId) {
const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId);
return { project, goal: defaultGoal };
}
return { project, goal: null };
}
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
router.param("id", async (req, res, next, rawId) => {
try {
@ -338,9 +311,14 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
resolveIssueProjectAndGoal(issue),
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
]);
@ -378,9 +356,14 @@ export function issueRoutes(db: Db, storage: StorageService) {
? req.query.wakeCommentId.trim()
: null;
const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([
resolveIssueProjectAndGoal(issue),
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
]);

View file

@ -3,54 +3,28 @@ type MaybeId = string | null | undefined;
export function resolveIssueGoalId(input: {
projectId: MaybeId;
goalId: MaybeId;
projectGoalId?: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
if (input.goalId) return input.goalId;
if (input.projectId) return input.projectGoalId ?? null;
return input.defaultGoalId ?? null;
if (!input.projectId && !input.goalId) {
return input.defaultGoalId ?? null;
}
return input.goalId ?? null;
}
export function resolveNextIssueGoalId(input: {
currentProjectId: MaybeId;
currentGoalId: MaybeId;
currentProjectGoalId?: MaybeId;
projectId?: MaybeId;
goalId?: MaybeId;
projectGoalId?: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
const projectId =
input.projectId !== undefined ? input.projectId : input.currentProjectId;
const projectGoalId =
input.projectGoalId !== undefined
? input.projectGoalId
: projectId
? input.currentProjectGoalId
: null;
const goalId =
input.goalId !== undefined ? input.goalId : input.currentGoalId;
const resolveFallbackGoalId = (targetProjectId: MaybeId, targetProjectGoalId: MaybeId) => {
if (targetProjectId) return targetProjectGoalId ?? null;
if (!projectId && !goalId) {
return input.defaultGoalId ?? null;
};
if (input.goalId !== undefined) {
return input.goalId ?? resolveFallbackGoalId(projectId, projectGoalId);
}
const currentFallbackGoalId = resolveFallbackGoalId(
input.currentProjectId,
input.currentProjectGoalId,
);
const nextFallbackGoalId = resolveFallbackGoalId(projectId, projectGoalId);
if (!input.currentGoalId) {
return nextFallbackGoalId;
}
if (input.currentGoalId === currentFallbackGoalId) {
return nextFallbackGoalId;
}
return input.currentGoalId;
return goalId ?? null;
}

View file

@ -101,7 +101,6 @@ type IssueUserContextInput = {
createdAt: Date | string;
updatedAt: Date | string;
};
type ProjectGoalReader = Pick<Db, "select">;
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
@ -114,20 +113,6 @@ function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}
async function getProjectDefaultGoalId(
db: ProjectGoalReader,
companyId: string,
projectId: string | null | undefined,
) {
if (!projectId) return null;
const row = await db
.select({ goalId: projects.goalId })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return row?.goalId ?? null;
}
function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
@ -759,7 +744,6 @@ export function issueService(db: Db) {
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
@ -811,7 +795,6 @@ export function issueService(db: Db) {
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
projectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
@ -912,21 +895,11 @@ export function issueService(db: Db) {
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
getProjectDefaultGoalId(
tx,
existing.companyId,
issueData.projectId !== undefined ? issueData.projectId : existing.projectId,
),
]);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
currentProjectGoalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
projectGoalId: nextProjectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
const updated = await tx

View file

@ -1,11 +0,0 @@
# @paperclipai/ui
Published static assets for the Paperclip board UI.
## What gets published
The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies.
## Typical use
Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`.

View file

@ -1,29 +1,13 @@
{
"name": "@paperclipai/ui",
"version": "0.3.1",
"description": "Prebuilt Paperclip board UI assets.",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "ui"
},
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b",
"clean": "rm -rf dist tsconfig.tsbuildinfo",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"publishConfig": {
"access": "public"
"typecheck": "tsc -b"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View file

@ -8,7 +8,6 @@ import { companiesApi } from "../api/companies";
import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import {
@ -25,11 +24,6 @@ import {
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId
} from "../lib/onboarding-launch";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
@ -150,11 +144,7 @@ export function OnboardingWizard() {
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
string | null
>(null);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
null
);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
useEffect(() => {
@ -170,10 +160,6 @@ export function OnboardingWizard() {
setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedAgentId(null);
setCreatedIssueRef(null);
}, [
effectiveOnboardingOpen,
effectiveOnboardingOptions.companyId,
@ -298,9 +284,7 @@ export function OnboardingWizard() {
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedAgentId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
}
@ -387,7 +371,7 @@ export function OnboardingWizard() {
if (companyGoal.trim()) {
const parsedGoal = parseOnboardingGoalInput(companyGoal);
const goal = await goalsApi.create(company.id, {
await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
@ -395,12 +379,9 @@ export function OnboardingWizard() {
level: "company",
status: "active"
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
} else {
setCreatedCompanyGoalId(null);
}
setStep(2);
@ -541,38 +522,16 @@ export function OnboardingWizard() {
setLoading(true);
setError(null);
try {
let goalId = createdCompanyGoalId;
if (!goalId) {
const goals = await goalsApi.list(createdCompanyId);
goalId = selectDefaultCompanyGoalId(goals);
setCreatedCompanyGoalId(goalId);
}
let projectId = createdProjectId;
if (!projectId) {
const project = await projectsApi.create(
createdCompanyId,
buildOnboardingProjectPayload(goalId)
);
projectId = project.id;
setCreatedProjectId(projectId);
queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(createdCompanyId)
});
}
let issueRef = createdIssueRef;
if (!issueRef) {
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: taskTitle,
description: taskDescription,
assigneeAgentId: createdAgentId,
projectId,
goalId
})
);
const issue = await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(),
...(taskDescription.trim()
? { description: taskDescription.trim() }
: {}),
assigneeAgentId: createdAgentId,
status: "todo"
});
issueRef = issue.identifier ?? issue.id;
setCreatedIssueRef(issueRef);
queryClient.invalidateQueries({

View file

@ -355,7 +355,7 @@
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: middle;
vertical-align: baseline;
white-space: nowrap;
user-select: none;
}
@ -746,7 +746,7 @@ a.paperclip-project-mention-chip {
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: middle;
vertical-align: baseline;
white-space: nowrap;
}

View file

@ -296,7 +296,6 @@ describe("inbox helpers", () => {
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
@ -306,37 +305,6 @@ describe("inbox helpers", () => {
]);
});
it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-oldest",
"pending",
"2026-03-11T02:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [issue],
approvals: [approval],
joinRequests: [joinRequest],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"join:join-1",
"approval:approval-oldest",
]);
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({

View file

@ -28,11 +28,6 @@ export type InboxWorkItem =
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}
| {
kind: "join_request";
timestamp: number;
joinRequest: JoinRequest;
};
export interface InboxBadgeData {
@ -157,12 +152,10 @@ export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
joinRequests = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
joinRequests?: JoinRequest[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
@ -180,11 +173,6 @@ export function getInboxWorkItems({
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
...joinRequests.map((joinRequest) => ({
kind: "join_request" as const,
timestamp: normalizeTimestamp(joinRequest.createdAt),
joinRequest,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;

View file

@ -1,131 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
} from "./onboarding-launch";
describe("selectDefaultCompanyGoalId", () => {
it("prefers the earliest active root company goal", () => {
expect(
selectDefaultCompanyGoalId([
{
id: "team-goal",
companyId: "company-1",
title: "Nested",
description: null,
level: "team",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-04T00:00:00Z"),
updatedAt: new Date("2026-03-04T00:00:00Z"),
},
{
id: "goal-2",
companyId: "company-1",
title: "Later active root",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-03T00:00:00Z"),
updatedAt: new Date("2026-03-03T00:00:00Z"),
},
{
id: "goal-1",
companyId: "company-1",
title: "Earliest active root",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
},
]),
).toBe("goal-1");
});
it("falls back to the earliest root company goal when none are active", () => {
expect(
selectDefaultCompanyGoalId([
{
id: "goal-2",
companyId: "company-1",
title: "Cancelled root",
description: null,
level: "company",
status: "cancelled",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-03T00:00:00Z"),
updatedAt: new Date("2026-03-03T00:00:00Z"),
},
{
id: "goal-1",
companyId: "company-1",
title: "Earliest root",
description: null,
level: "company",
status: "planned",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
},
]),
).toBe("goal-1");
});
});
describe("onboarding launch payloads", () => {
it("links the onboarding project and first issue to the selected goal", () => {
expect(buildOnboardingProjectPayload("goal-1")).toEqual({
name: "Onboarding",
status: "in_progress",
goalIds: ["goal-1"],
});
expect(
buildOnboardingIssuePayload({
title: " Hire your first engineer ",
description: " Kick off the hiring plan ",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: "goal-1",
}),
).toEqual({
title: "Hire your first engineer",
description: "Kick off the hiring plan",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: "goal-1",
status: "todo",
});
});
it("omits goal links when no default company goal exists", () => {
expect(buildOnboardingProjectPayload(null)).toEqual({
name: "Onboarding",
status: "in_progress",
});
expect(
buildOnboardingIssuePayload({
title: "Task",
description: "",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: null,
}),
).toEqual({
title: "Task",
assigneeAgentId: "agent-1",
projectId: "project-1",
status: "todo",
});
});
});

View file

@ -1,53 +0,0 @@
import type { Goal } from "@paperclipai/shared";
export const ONBOARDING_PROJECT_NAME = "Onboarding";
function goalCreatedAt(goal: Goal) {
const createdAt = goal.createdAt instanceof Date ? goal.createdAt : new Date(goal.createdAt);
return Number.isNaN(createdAt.getTime()) ? 0 : createdAt.getTime();
}
function pickEarliestGoal(goals: Goal[]) {
return [...goals].sort((a, b) => goalCreatedAt(a) - goalCreatedAt(b))[0] ?? null;
}
export function selectDefaultCompanyGoalId(goals: Goal[]): string | null {
const companyGoals = goals.filter((goal) => goal.level === "company");
const rootGoals = companyGoals.filter((goal) => !goal.parentId);
const activeRootGoals = rootGoals.filter((goal) => goal.status === "active");
return (
pickEarliestGoal(activeRootGoals)?.id ??
pickEarliestGoal(rootGoals)?.id ??
pickEarliestGoal(companyGoals)?.id ??
null
);
}
export function buildOnboardingProjectPayload(goalId: string | null) {
return {
name: ONBOARDING_PROJECT_NAME,
status: "in_progress" as const,
...(goalId ? { goalIds: [goalId] } : {}),
};
}
export function buildOnboardingIssuePayload(input: {
title: string;
description: string;
assigneeAgentId: string;
projectId: string;
goalId: string | null;
}) {
const title = input.title.trim();
const description = input.description.trim();
return {
title,
...(description ? { description } : {}),
assigneeAgentId: input.assigneeAgentId,
projectId: input.projectId,
...(input.goalId ? { goalId: input.goalId } : {}),
status: "todo" as const,
};
}

View file

@ -36,7 +36,6 @@ import {
XCircle,
X,
RotateCcw,
UserPlus,
} from "lucide-react";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
@ -62,6 +61,7 @@ type InboxCategoryFilter =
| "alerts";
type SectionKey =
| "work_items"
| "join_requests"
| "alerts";
function firstNonEmptyLine(value: string | null | undefined): string | null {
@ -281,84 +281,6 @@ function ApprovalInboxRow({
);
}
function JoinRequestInboxRow({
joinRequest,
onApprove,
onReject,
isPending,
}: {
joinRequest: JoinRequest;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
}) {
const label =
joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<UserPlus className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}</span>
{joinRequest.adapterType && <span>adapter: {joinRequest.adapterType}</span>}
</span>
</span>
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
);
}
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@ -509,22 +431,14 @@ export function Inbox() {
return failedRuns;
}, [failedRuns, tab, showFailedRunsCategory]);
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "recent") return joinRequests;
if (tab === "unread") return joinRequests;
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory]);
const workItemsToRender = useMemo(
() =>
getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
failedRuns: failedRunsForTab,
joinRequests: joinRequestsForTab,
}),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
);
const agentName = (id: string | null) => {
@ -688,7 +602,10 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const hasJoinRequests = joinRequests.length > 0;
const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
@ -699,6 +616,7 @@ export function Inbox() {
const visibleSections = [
showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null);
@ -839,18 +757,6 @@ export function Inbox() {
);
}
if (item.kind === "join_request") {
return (
<JoinRequestInboxRow
key={`join:${item.joinRequest.id}`}
joinRequest={item.joinRequest}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
/>
);
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
@ -900,6 +806,61 @@ export function Inbox() {
</>
)}
{showJoinRequestsSection && (
<>
{showSeparatorBefore("join_requests") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Join Requests
</h3>
<div className="grid gap-3">
{joinRequests.map((joinRequest) => (
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
</p>
<p className="text-xs text-muted-foreground">
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
</p>
{joinRequest.requestEmailSnapshot && (
<p className="text-xs text-muted-foreground">
email: {joinRequest.requestEmailSnapshot}
</p>
)}
{joinRequest.adapterType && (
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => rejectJoinMutation.mutate(joinRequest)}
>
Reject
</Button>
<Button
size="sm"
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
onClick={() => approveJoinMutation.mutate(joinRequest)}
>
Approve
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
{showAlertsSection && (
<>
{showSeparatorBefore("alerts") && <Separator />}