Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
dotta
712ccc802f docs: fix SPEC accuracy for adapters and backend
- align adapter list with current built-in adapters
- update backend framework references to Express
- remove outdated V1 not-supported template export claim
- clarify work artifact boundaries with issue documents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-26 01:06:27 -05:00
dotta
bfe97e08f7 Publish @paperclipai/ui from release automation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 19:30:37 -05:00
dotta
e7883135f3 edited the changelog 2026-03-25 07:38:37 -05:00
dotta
37cce49328 Add v2026.325.0 release changelog
Covers all changes since v2026.318.0 including company import/export,
company skills library, routines engine, board CLI auth, and eval
framework bootstrap, plus numerous UI fixes and improvements.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 07:31:46 -05:00
dotta
a34eb3766f Fix agent mention pill vertical misalignment with project mention pill
Change vertical-align from baseline to middle on both editor and
read-only mention chip styles. The baseline alignment caused
inconsistent positioning because the agent ::before icon (0.75rem)
and project ::before dot (0.45rem) produced different synthesized
baselines in the inline-flex containers.

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

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

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 07:44:30 -05:00
dotta
bd5c988728 Improve CLI API connection errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 07:04:26 -05:00
dotta
52dab938cb Extract mention-aware link node helper and add tests
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:41:50 -05:00
dotta
334e7e61b5 Fix instructions tab state on agent switch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:30:18 -05:00
dotta
eda69fed74 Fix imported agent bundle frontmatter leakage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-23 20:04:40 -05:00
47 changed files with 1298 additions and 217 deletions

View file

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

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => {
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()

View file

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

View file

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

View file

@ -51,10 +51,9 @@ Public packages are discovered from:
- `packages/`
- `server/`
- `ui/`
- `cli/`
`ui/` is ignored because it is private.
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
- finds all public packages
@ -65,6 +64,18 @@ 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:
@ -135,6 +146,7 @@ This is the fastest way to restore the default install path if a stable release
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -221,10 +221,6 @@ 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,10 +266,6 @@ 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"],
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});

View file

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

View file

@ -0,0 +1,22 @@
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,6 +4,11 @@ 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:
.:
@ -22,7 +27,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(@noble/hashes@2.0.1))(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)(lightningcss@1.30.2)(tsx@4.21.0)
cli:
dependencies:
@ -73,7 +78,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
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
picocolors:
specifier: ^1.1.1
version: 1.1.1
@ -225,7 +230,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
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
postgres:
specifier: ^3.4.5
version: 3.4.8
@ -244,7 +249,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(@noble/hashes@2.0.1))(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)(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/create-paperclip-plugin:
dependencies:
@ -294,7 +299,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(@noble/hashes@2.0.1))(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)(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/examples/plugin-file-browser-example:
dependencies:
@ -494,7 +499,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
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
express:
specifier: ^5.1.0
version: 5.2.1
@ -570,7 +575,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(@noble/hashes@2.0.1))(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)(lightningcss@1.30.2)(tsx@4.21.0)
ui:
dependencies:
@ -691,7 +696,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(@noble/hashes@2.0.1))(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)(lightningcss@1.30.2)(tsx@4.21.0)
packages:
@ -9973,7 +9978,7 @@ snapshots:
electron-to-chromium@1.5.286: {}
embedded-postgres@18.1.0-beta.16:
embedded-postgres@18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem):
dependencies:
async-exit-hook: 2.0.1
pg: 8.18.0
@ -12169,8 +12174,52 @@ snapshots:
- terser
- tsx
- yaml
optional: true
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):
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):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4

77
releases/v2026.325.0.md Normal file
View file

@ -0,0 +1,77 @@
# 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

@ -0,0 +1,31 @@
#!/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

@ -2108,5 +2108,8 @@ describe("company portability", () => {
replaceExisting: true,
}),
);
const materializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls[0]?.[1] as Record<string, string>;
expect(materializedFiles["AGENTS.md"]).not.toMatch(/^---\n/);
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
});

View file

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

View file

@ -0,0 +1,57 @@
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,16 +20,29 @@ describe("issue goal fallback", () => {
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
projectGoalId: "goal-3",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
it("inherits the project goal when creating a project-linked issue", () => {
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();
@ -40,20 +53,47 @@ describe("issue goal fallback", () => {
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
currentProjectGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
it("switches from the company fallback to the project goal 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",
}),
).toBeNull();
).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");
});
});

View file

@ -0,0 +1,187 @@
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"],
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});

View file

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

View file

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

View file

@ -1411,6 +1411,25 @@ 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;
@ -2618,17 +2637,8 @@ export function accessRoutes(
"member",
"active"
);
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"
const grants = agentJoinGrantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null
);
await access.setPrincipalGrants(
companyId,

View file

@ -171,6 +171,33 @@ 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 {
@ -311,14 +338,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
resolveIssueProjectAndGoal(issue),
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),
]);
@ -356,14 +378,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
? req.query.wakeCommentId.trim()
: null;
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([
resolveIssueProjectAndGoal(issue),
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

@ -3864,6 +3864,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
: []),
);
const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path);
const entryRelativePath = normalizePortablePath(manifestAgent.path).startsWith(bundlePrefix)
? normalizePortablePath(manifestAgent.path).slice(bundlePrefix.length)
: "AGENTS.md";
if (typeof markdownRaw === "string") {
const importedInstructionsBody = parseFrontmatterMarkdown(markdownRaw).body;
bundleFiles[entryRelativePath] = importedInstructionsBody;
if (entryRelativePath !== "AGENTS.md" && !bundleFiles["AGENTS.md"]) {
bundleFiles["AGENTS.md"] = importedInstructionsBody;
}
}
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
if (!markdownRaw && fallbackPromptTemplate) {
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;

View file

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

View file

@ -101,6 +101,7 @@ 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;
@ -113,6 +114,20 @@ 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>`
(
@ -744,6 +759,7 @@ 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) {
@ -795,6 +811,7 @@ export function issueService(db: Db) {
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
projectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
@ -895,11 +912,21 @@ 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

11
ui/README.md Normal file
View file

@ -0,0 +1,11 @@
# @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,13 +1,29 @@
{
"name": "@paperclipai/ui",
"version": "0.0.1",
"private": true,
"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"
},
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
"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"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View file

@ -26,53 +26,13 @@ import {
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { LinkNode, type LinkAttributes } from "@lexical/link";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { cn } from "../lib/utils";
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
class MentionAwareLinkNode extends LinkNode {
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
return new MentionAwareLinkNode(
node.getURL(),
{
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
node.getKey(),
);
}
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
super(url, attributes, key);
}
sanitizeUrl(url: string): string {
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
return super.sanitizeUrl(url);
}
}
const mentionAwareLinkNodeReplacement = {
replace: LinkNode,
with: (node: LinkNode) =>
new MentionAwareLinkNode(
node.getURL(),
{
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
node.getKey(),
),
withKlass: MentionAwareLinkNode,
} as const;
/* ---- Mention types ---- */
export interface MentionOption {
@ -590,7 +550,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
additionalLexicalNodes={[mentionAwareLinkNodeReplacement]}
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
plugins={plugins}
/>

View file

@ -8,6 +8,7 @@ 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 {
@ -24,6 +25,11 @@ 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
@ -144,7 +150,11 @@ 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(() => {
@ -160,6 +170,10 @@ export function OnboardingWizard() {
setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedAgentId(null);
setCreatedIssueRef(null);
}, [
effectiveOnboardingOpen,
effectiveOnboardingOptions.companyId,
@ -284,7 +298,9 @@ export function OnboardingWizard() {
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedAgentId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
}
@ -371,7 +387,7 @@ export function OnboardingWizard() {
if (companyGoal.trim()) {
const parsedGoal = parseOnboardingGoalInput(companyGoal);
await goalsApi.create(company.id, {
const goal = await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
@ -379,9 +395,12 @@ export function OnboardingWizard() {
level: "company",
status: "active"
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
} else {
setCreatedCompanyGoalId(null);
}
setStep(2);
@ -522,16 +541,38 @@ 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, {
title: taskTitle.trim(),
...(taskDescription.trim()
? { description: taskDescription.trim() }
: {}),
assigneeAgentId: createdAgentId,
status: "todo"
});
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: taskTitle,
description: taskDescription,
assigneeAgentId: createdAgentId,
projectId,
goalId
})
);
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: baseline;
vertical-align: middle;
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: baseline;
vertical-align: middle;
white-space: nowrap;
}

View file

@ -296,6 +296,7 @@ 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([
@ -305,6 +306,37 @@ 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,6 +28,11 @@ export type InboxWorkItem =
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}
| {
kind: "join_request";
timestamp: number;
joinRequest: JoinRequest;
};
export interface InboxBadgeData {
@ -152,10 +157,12 @@ export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
joinRequests = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
joinRequests?: JoinRequest[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
@ -173,6 +180,11 @@ 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

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { $createLinkNode } from "@lexical/link";
import { createEditor } from "lexical";
import {
MentionAwareLinkNode,
getMentionAwareLinkNodeInit,
mentionAwareLinkNodeReplacement,
} from "./mention-aware-link-node";
function createTestEditor() {
return createEditor({
namespace: "mention-aware-link-node-test",
nodes: [MentionAwareLinkNode, mentionAwareLinkNodeReplacement],
onError(error: Error) {
throw error;
},
});
}
describe("getMentionAwareLinkNodeInit", () => {
it("copies link attributes without carrying over a node key", () => {
const init = getMentionAwareLinkNodeInit({
getURL: () => "agent://agent-123",
getRel: () => "noreferrer",
getTarget: () => "_blank",
getTitle: () => "Agent mention",
});
expect(Object.keys(init)).toEqual(["url", "attributes"]);
expect(init).toEqual({
url: "agent://agent-123",
attributes: {
rel: "noreferrer",
target: "_blank",
title: "Agent mention",
},
});
});
it("replaces LinkNode creation with MentionAwareLinkNode without throwing", () => {
const editor = createTestEditor();
let created: unknown;
editor.update(() => {
created = $createLinkNode("agent://agent-123");
});
expect(created).toBeInstanceOf(MentionAwareLinkNode);
});
});

View file

@ -0,0 +1,67 @@
import {
LinkNode,
type LinkAttributes,
type SerializedLinkNode,
} from "@lexical/link";
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
export class MentionAwareLinkNode extends LinkNode {
static getType(): string {
return "mention-aware-link";
}
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
return new MentionAwareLinkNode(
node.getURL(),
{
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
node.getKey(),
);
}
static importJSON(serializedNode: SerializedLinkNode): MentionAwareLinkNode {
return new MentionAwareLinkNode(
serializedNode.url ?? "",
{
rel: serializedNode.rel ?? null,
target: serializedNode.target ?? null,
title: serializedNode.title ?? null,
},
);
}
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
super(url, attributes, key);
}
sanitizeUrl(url: string): string {
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
return super.sanitizeUrl(url);
}
}
type MentionAwareLinkSource = Pick<LinkNode, "getURL" | "getRel" | "getTarget" | "getTitle">;
export function getMentionAwareLinkNodeInit(node: MentionAwareLinkSource) {
return {
url: node.getURL(),
attributes: {
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
};
}
export const mentionAwareLinkNodeReplacement = {
replace: LinkNode,
with: (node: LinkNode) => {
const { url, attributes } = getMentionAwareLinkNodeInit(node);
return new MentionAwareLinkNode(url, attributes);
},
withKlass: MentionAwareLinkNode,
} as const;

View file

@ -0,0 +1,131 @@
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

@ -0,0 +1,53 @@
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

@ -998,6 +998,7 @@ export function AgentDetail() {
{activeView === "instructions" && (
<PromptsTab
key={agent.id}
agent={agent}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
@ -1617,6 +1618,20 @@ function PromptsTab({
selectedFile: string;
} | null>(null);
useEffect(() => {
setSelectedFile("AGENTS.md");
setShowFilePanel(false);
setDraft(null);
setBundleDraft(null);
setNewFilePath("");
setShowNewFileInput(false);
setPendingFiles([]);
setExpandedDirs(new Set());
setAwaitingRefresh(false);
lastFileVersionRef.current = null;
externalBundleRef.current = null;
}, [agent.id]);
const isLocal =
agent.adapterType === "claude_local" ||
agent.adapterType === "codex_local" ||

View file

@ -36,6 +36,7 @@ import {
XCircle,
X,
RotateCcw,
UserPlus,
} from "lucide-react";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
@ -61,7 +62,6 @@ type InboxCategoryFilter =
| "alerts";
type SectionKey =
| "work_items"
| "join_requests"
| "alerts";
function firstNonEmptyLine(value: string | null | undefined): string | null {
@ -281,6 +281,84 @@ 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();
@ -431,14 +509,22 @@ 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],
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
);
const agentName = (id: string | null) => {
@ -602,10 +688,7 @@ 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,
@ -616,7 +699,6 @@ export function Inbox() {
const visibleSections = [
showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null);
@ -757,6 +839,18 @@ 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);
@ -806,61 +900,6 @@ 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 />}