Compare commits

..

44 Commits

Author SHA1 Message Date
Dotta
db81a06386 docs: add agent evals framework plan 2026-03-13 15:07:56 -05:00
Dotta
aaadbdc144 Merge pull request #790 from paperclipai/paperclip-token-optimization
Optimize heartbeat token usage
2026-03-13 15:01:45 -05:00
Dotta
a393db78b4 fix: address greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 14:53:30 -05:00
Dotta
c1430e7b06 docs: add paperclip skill tightening plan
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 14:37:44 -05:00
Dotta
528505a04a fix: isolate codex home in worktrees 2026-03-13 11:53:56 -05:00
Dotta
e2a0347c6d Merge pull request #805 from paperclipai/fix/worktree-ui-branding
Add worktree UI branding
2026-03-13 11:15:11 -05:00
Dotta
cce9941464 Add worktree UI branding 2026-03-13 11:12:43 -05:00
Dotta
d51c4b1a4c fix: tighten token optimization edge cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 10:18:00 -05:00
Dotta
3b0d9a93f4 Merge pull request #802 from paperclipai/fix/ui-routing-and-assignee-polish
fix(ui): polish company switching, issue tab order, and assignee filters
2026-03-13 10:11:09 -05:00
Dotta
41eb8e51e3 Fix company switch remembered routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 10:01:32 -05:00
Dotta
32ab4f8e47 Add me and unassigned assignee options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
6365e03731 feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
When creating a new issue with a pre-filled assignee or project (e.g. from
a project page), Tab from the title field now skips over fields that already
have values, going directly to the next empty field or description.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
2b9de934e3 Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
Dotta
4a368f54d5 Delay onboarding starter task creation until launch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:46:36 -05:00
Dotta
7d1748b3a7 feat: optimize heartbeat token usage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
2246d5f1eb Add me and unassigned assignee options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
575a2fd83f feat: skip pre-filled assignee/project fields when tabbing in new issue dialog
When creating a new issue with a pre-filled assignee or project (e.g. from
a project page), Tab from the title field now skips over fields that already
have values, going directly to the next empty field or description.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
c9259bbec0 Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
f3c18db7dd Delay onboarding starter task creation until launch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:40:43 -05:00
Dotta
43baf709dd Merge pull request #797 from paperclipai/fix/embedded-postgres-initdbflags
fix: align embedded postgres ctor types with initdbFlags usage
2026-03-13 09:39:27 -05:00
Dotta
24d6e3a543 fix: align embedded postgres ctor types with initdbFlags usage 2026-03-13 09:25:04 -05:00
Dotta
0b8223b8b9 Merge pull request #796 from paperclipai/docs/organize-and-date-plans
docs: consolidate dated plan docs and naming guidance
2026-03-13 09:20:25 -05:00
Dotta
e2f0241533 docs: add dated plan naming rule and align workspace plan 2026-03-13 09:16:28 -05:00
Dotta
89e247b410 Expand workspace plan for migration and cloud execution 2026-03-13 09:15:36 -05:00
Dotta
216cb3fb28 Add workspace product model plan 2026-03-13 09:15:36 -05:00
Dotta
84fc6d4a87 docs: add token optimization plan
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:15:36 -05:00
Dotta
9c7d9ded1e docs: organize plans into doc/plans with date prefixes
Move plans from doc/plan/ into doc/plans/ and add YYYY-MM-DD date
prefixes to all undated plan files based on document headers or
earliest git commit dates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:11:56 -05:00
Dotta
dfe40ffcca Merge pull request #793 from paperclipai/split/docs-sidebar-link
ui: point Documentation sidebar link to docs.paperclip.ing
2026-03-13 09:09:30 -05:00
Dotta
f477f23738 Merge pull request #794 from paperclipai/split/agent-skill-resolution
fix(adapters): resolve local agent skill lookup after .agents migration
2026-03-13 09:09:22 -05:00
Dotta
29a743cb9e fix: keep runtime skills scoped to ./skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 08:49:25 -05:00
Dotta
4e759da070 fix: prefer .agents skills and repair codex symlink targets\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing> 2026-03-13 08:49:25 -05:00
Dotta
69b9e45eaf Change sidebar Documentation link to external docs.paperclip.ing
The sidebar Documentation links were pointing to an internal /docs route.
Updated both mobile and desktop sidebar instances to link to
https://docs.paperclip.ing/ in a new tab instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:49:24 -05:00
Paperclip
5c7d2116e9 Fix local-cli skill install for moved .agents skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 08:49:24 -05:00
Dotta
c32d19415b Merge pull request #783 from paperclipai/docs/product-and-features-plan
docs: update PRODUCT.md and add features plan
2026-03-13 07:25:52 -05:00
Dotta
0a0d74eb94 Merge pull request #782 from paperclipai/feat/worktree-cleanup-and-env
feat(worktree): cleanup command, env var defaults, auto-prefix
2026-03-13 07:25:31 -05:00
Dotta
2c5e48993d docs: update PRODUCT.md and add 2026-03-13 features plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:25:23 -05:00
Dotta
77af1ae544 feat(worktree): add worktree:cleanup command, env var defaults, and auto-prefix
- Add `worktree:cleanup <name>` command that safely removes a worktree,
  its branch, and instance data. Idempotent and safe by default — refuses
  to delete branches with unique unmerged commits or worktrees with
  uncommitted changes unless --force is passed.
- Support PAPERCLIP_WORKTREES_DIR env var as default for --home across
  all worktree commands.
- Support PAPERCLIP_WORKTREE_START_POINT env var as default for
  --start-point on worktree:make.
- Auto-prefix worktree names with "paperclip-" if not already present,
  so `worktree:make subissues` creates ~/paperclip-subissues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:24:39 -05:00
Dotta
872d2434a9 Merge pull request #251 from mjaverto/fix/process-lost-reaper
fix(heartbeat): prevent false process_lost failures on queued and non-child-process runs
2026-03-13 07:06:10 -05:00
Dotta
fe764cac75 fix: resolve type errors in process-lost-reaper PR
- Fix malformed try/catch/finally blocks in heartbeat executeRun
- Declare activeRunExecutions Set to track in-flight runs
- Add resumeQueuedRuns function and export from heartbeat service
- Add initdbFlags to EmbeddedPostgresCtor type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:56:31 -05:00
Dotta
f81d37fbf7 fix(heartbeat): prevent false process_lost failures on queued and non-child-process runs
- reapOrphanedRuns() now only scans running runs; queued runs are
  legitimately absent from runningProcesses (waiting on concurrency
  limits or issue locks) so including them caused false process_lost
  failures (closes #90)
- Add module-level activeRunExecutions set so non-child-process adapters
  (http, openclaw) are protected from the reaper during execution
- Add resumeQueuedRuns() to restart persisted queued runs after a server
  restart, called at startup and each periodic tick
- Add outer catch in executeRun() so setup failures (ensureRuntimeState,
  resolveWorkspaceForRun, etc.) are recorded as failed runs instead of
  leaving them stuck in running state
- Guard resumeQueuedRuns() against paused/terminated/pending_approval agents
- Increase opencode models discovery timeout from 20s to 45s
2026-03-12 17:24:50 -04:00
Dotta
d14e656ec1 Merge pull request #645 from vkartaviy/fix/embedded-postgres-utf8
fix: ensure embedded PostgreSQL databases use UTF-8 encoding
2026-03-12 14:15:58 -05:00
Dotta
5201222ce7 Merge pull request #711 from paperclipai/revert-pr-707
Revert PR #707
2026-03-12 12:17:43 -05:00
Dotta
b888f92718 Revert "Merge pull request #707 from paperclipai/nm/premerge-lockfile-refresh"
This reverts commit 56df8d3cf0, reversing
changes made to ac82cae39a.
2026-03-12 12:13:39 -05:00
Volodymyr Kartavyi
057e3a494c fix: ensure embedded PostgreSQL databases use UTF-8 encoding
On macOS, `initdb` defaults to SQL_ASCII encoding because it infers
locale from the system environment. When `ensurePostgresDatabase()`
creates a database without specifying encoding, the new database
inherits SQL_ASCII from the cluster. This causes string functions like
`left()` to operate on bytes instead of characters, producing invalid
UTF-8 when multi-byte characters are truncated.

Two-part fix:
1. Pass `--encoding=UTF8 --locale=C` via `initdbFlags` to all
   EmbeddedPostgres constructors so the cluster defaults to UTF-8.
2. Explicitly set `encoding 'UTF8'` in the CREATE DATABASE statement
   with `template template0` (required because template1 may already
   have a different encoding) and `C` locale for portability.

Existing databases created with SQL_ASCII are NOT automatically fixed;
users must delete their local `data/db` directory and restart to
re-initialize the cluster.

Relates to #636

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:59:02 +01:00
90 changed files with 6526 additions and 788 deletions

View File

@@ -13,8 +13,6 @@ jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pull-requests: read
steps:
- name: Checkout repository
@@ -33,38 +31,19 @@ jobs:
with:
node-version: 20
- name: Enforce lockfile policy when manifests change
env:
GH_TOKEN: ${{ github.token }}
- name: Block manual lockfile edits
if: github.head_ref != 'chore/refresh-lockfile'
run: |
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
manifest_changed=false
lockfile_changed=false
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
manifest_changed=true
fi
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
lockfile_changed=true
fi
if [ "$lockfile_changed" = true ] && [ "$manifest_changed" != true ]; then
echo "pnpm-lock.yaml changed without a dependency manifest change." >&2
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
fi
if [ "$manifest_changed" = true ]; then
- name: Validate dependency resolution when manifests change
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
if ! git diff --quiet -- pnpm-lock.yaml; then
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
echo "pnpm-lock.yaml is stale for this PR. Wait for the Refresh Lockfile workflow to push the bot commit, then rerun checks." >&2
else
echo "pnpm-lock.yaml is stale for this fork PR. Run pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile and commit pnpm-lock.yaml." >&2
fi
exit 1
fi
fi

View File

@@ -30,7 +30,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck

View File

@@ -1,111 +0,0 @@
name: Refresh Lockfile
on:
pull_request:
branches:
- master
types:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: refresh-lockfile-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: read
steps:
- name: Detect dependency manifest changes
id: changes
env:
GH_TOKEN: ${{ github.token }}
run: |
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
else
echo "manifest_changed=false" >> "$GITHUB_OUTPUT"
fi
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
echo "same_repo=true" >> "$GITHUB_OUTPUT"
else
echo "same_repo=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout pull request head
if: steps.changes.outputs.manifest_changed == 'true'
uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Setup pnpm
if: steps.changes.outputs.manifest_changed == 'true'
uses: pnpm/action-setup@v4
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
if: steps.changes.outputs.manifest_changed == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
if: steps.changes.outputs.manifest_changed == 'true'
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
if: steps.changes.outputs.manifest_changed == 'true'
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Commit refreshed lockfile to same-repo PR branch
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo == 'true'
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
exit 0
fi
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push origin "HEAD:${{ github.event.pull_request.head.ref }}"
- name: Fail fork PRs that need a lockfile refresh
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo != 'true'
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
exit 0
fi
echo "This fork PR changes dependency manifests and requires a refreshed pnpm-lock.yaml." >&2
echo "Run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile" >&2
echo "Then commit pnpm-lock.yaml to the PR branch." >&2
exit 1

81
.github/workflows/refresh-lockfile.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Refresh Lockfile
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: refresh-lockfile-master
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Create or update pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push --force origin "$BRANCH"
# Create PR if one doesn't already exist
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
if [ -z "$existing" ]; then
gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
echo "Created new PR."
else
echo "PR #$existing already exists, branch updated via force push."
fi

View File

@@ -78,6 +78,9 @@ If you change schema/API behavior, update all impacted layers:
4. Do not replace strategic docs wholesale unless asked.
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
5. Keep plan docs dated and centralized.
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
## 6. Database Change Workflow
When changing data model:

View File

@@ -16,6 +16,7 @@ import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
generateWorktreeColor,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
@@ -181,13 +182,22 @@ describe("worktree helpers", () => {
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths);
const env = buildWorktreeEnvEntries(paths, {
name: "feature-worktree-support",
color: "#3abf7a",
});
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("generates vivid worktree colors as hex", () => {
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
@@ -280,7 +290,10 @@ describe("worktree helpers", () => {
});
const envPath = path.join(repoRoot, ".paperclip", ".env");
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
const envContents = fs.readFileSync(envPath, "utf8");
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=#[0-9a-f]{6}/);
} finally {
process.chdir(originalCwd);
if (originalJwtSecret === undefined) {

View File

@@ -1,5 +1,9 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -34,15 +38,12 @@ interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
path.resolve(process.cwd(), "skills"),
];
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
@@ -56,14 +57,6 @@ function claudeSkillsHome(): string {
return path.join(base, "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
@@ -73,20 +66,65 @@ async function installSkillsForTarget(
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
summary.skipped.push(entry.name);
continue;
if (existing.isSymbolicLink()) {
let linkedPath: string | null = null;
try {
linkedPath = await fs.readlink(target);
} catch (err) {
await fs.unlink(target);
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
continue;
} catch (linkErr) {
summary.failed.push({
name: entry.name,
error:
err instanceof Error && linkErr instanceof Error
? `${err.message}; then ${linkErr.message}`
: err instanceof Error
? err.message
: `Failed to recover broken symlink: ${String(err)}`,
});
continue;
}
}
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
const linkedTargetExists = await fs
.stat(resolvedLinkedPath)
.then(() => true)
.catch(() => false);
if (!linkedTargetExists) {
await fs.unlink(target);
} else {
summary.skipped.push(entry.name);
continue;
}
} else {
summary.skipped.push(entry.name);
continue;
}
}
try {
@@ -210,7 +248,7 @@ export function registerAgentCommands(program: Command): void {
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir();
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
@@ -258,7 +296,7 @@ export function registerAgentCommands(program: Command): void {
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);

View File

@@ -1,3 +1,4 @@
import { randomInt } from "node:crypto";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import { expandHomePrefix } from "../config/home.js";
@@ -44,6 +45,11 @@ export type WorktreeLocalPaths = {
storageDir: string;
};
export type WorktreeUiBranding = {
name: string;
color: string;
};
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
}
@@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string)
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
}
function hslComponentToHex(n: number): string {
return Math.round(Math.max(0, Math.min(255, n)))
.toString(16)
.padStart(2, "0");
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const s = Math.max(0, Math.min(100, saturation)) / 100;
const l = Math.max(0, Math.min(100, lightness)) / 100;
const c = (1 - Math.abs((2 * l) - 1)) * s;
const h = ((hue % 360) + 360) % 360;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - (c / 2);
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
}
export function generateWorktreeColor(): string {
return hslToHex(randomInt(0, 360), 68, 56);
}
export function resolveWorktreeLocalPaths(opts: {
cwd: string;
homeDir?: string;
@@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: {
};
}
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
export function buildWorktreeEnvEntries(
paths: WorktreeLocalPaths,
branding?: WorktreeUiBranding,
): Record<string, string> {
return {
PAPERCLIP_HOME: paths.homeDir,
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
};
}

View File

@@ -39,6 +39,7 @@ import {
buildWorktreeEnvEntries,
DEFAULT_WORKTREE_HOME,
formatShellExports,
generateWorktreeColor,
isWorktreeSeedMode,
resolveSuggestedWorktreeName,
resolveWorktreeSeedPlan,
@@ -83,6 +84,7 @@ type EmbeddedPostgresCtor = new (opts: {
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
@@ -127,6 +129,8 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
}
const WORKTREE_NAME_PREFIX = "paperclip-";
function resolveWorktreeMakeName(name: string): string {
const value = nonEmpty(name);
if (!value) {
@@ -137,7 +141,15 @@ function resolveWorktreeMakeName(name: string): string {
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
}
return value;
return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`;
}
function resolveWorktreeHome(explicit?: string): string {
return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME;
}
function resolveWorktreeStartPoint(explicit?: string): string | undefined {
return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined;
}
export function resolveWorktreeMakeTargetPath(name: string): string {
@@ -514,6 +526,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
@@ -611,7 +624,7 @@ async function seedWorktreeDatabase(input: {
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
const worktreeName = resolveSuggestedWorktreeName(
cwd,
opts.name ?? detectGitBranchName(cwd) ?? undefined,
);
@@ -619,12 +632,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName);
const paths = resolveWorktreeLocalPaths({
cwd,
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
homeDir: resolveWorktreeHome(opts.home),
instanceId,
});
const branding = {
name: worktreeName,
color: generateWorktreeColor(),
};
const sourceConfigPath = resolveSourceConfigPath(opts);
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
@@ -657,7 +674,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
mergePaperclipEnvEntries(
{
...buildWorktreeEnvEntries(paths),
...buildWorktreeEnvEntries(paths, branding),
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
},
paths.envPath,
@@ -698,6 +715,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`));
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
if (copiedGitHooks?.copied) {
p.log.message(
@@ -731,6 +749,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
const name = resolveWorktreeMakeName(nameArg);
const startPoint = resolveWorktreeStartPoint(opts.startPoint);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
if (existsSync(targetPath)) {
@@ -738,8 +757,8 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
}
mkdirSync(path.dirname(targetPath), { recursive: true });
if (opts.startPoint) {
const [remote] = opts.startPoint.split("/", 1);
if (startPoint) {
const [remote] = startPoint.split("/", 1);
try {
execFileSync("git", ["fetch", remote], {
cwd: sourceCwd,
@@ -755,8 +774,8 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
const worktreeArgs = resolveGitWorktreeAddArgs({
branchName: name,
targetPath,
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
startPoint: opts.startPoint,
branchExists: !startPoint && localBranchExists(sourceCwd, name),
startPoint,
});
const spinner = p.spinner();
@@ -799,6 +818,232 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
}
}
type WorktreeCleanupOptions = {
instance?: string;
home?: string;
force?: boolean;
};
type GitWorktreeListEntry = {
worktree: string;
branch: string | null;
bare: boolean;
detached: boolean;
};
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const entries: GitWorktreeListEntry[] = [];
let current: Partial<GitWorktreeListEntry> = {};
for (const line of raw.split("\n")) {
if (line.startsWith("worktree ")) {
current = { worktree: line.slice("worktree ".length) };
} else if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length);
} else if (line === "bare") {
current.bare = true;
} else if (line === "detached") {
current.detached = true;
} else if (line === "" && current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
bare: current.bare ?? false,
detached: current.detached ?? false,
});
current = {};
}
}
if (current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
bare: current.bare ?? false,
detached: current.detached ?? false,
});
}
return entries;
}
function branchHasUniqueCommits(cwd: string, branchName: string): boolean {
try {
const output = execFileSync(
"git",
["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"],
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean {
try {
const output = execFileSync(
"git",
["branch", "-r", "--list", `*/${branchName}`],
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
function worktreePathHasUncommittedChanges(worktreePath: string): boolean {
try {
const output = execFileSync(
"git",
["status", "--porcelain"],
{ cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
const name = resolveWorktreeMakeName(nameArg);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home)));
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
// ── 1. Assess current state ──────────────────────────────────────────
const hasBranch = localBranchExists(sourceCwd, name);
const hasTargetDir = existsSync(targetPath);
const hasInstanceData = existsSync(instanceRoot);
const worktrees = parseGitWorktreeList(sourceCwd);
const linkedWorktree = worktrees.find(
(wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath),
);
if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) {
p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found.");
p.outro(pc.green("Already clean."));
return;
}
// ── 2. Safety checks ────────────────────────────────────────────────
const problems: string[] = [];
if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) {
const onRemote = branchExistsOnAnyRemote(sourceCwd, name);
if (onRemote) {
p.log.info(
`Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`,
);
} else {
problems.push(
`Branch "${name}" has commits not found on any other branch or remote. ` +
`Deleting it will lose work. Push it first, or use --force.`,
);
}
}
if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) {
problems.push(
`Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`,
);
}
if (problems.length > 0 && !opts.force) {
for (const problem of problems) {
p.log.error(problem);
}
throw new Error("Safety checks failed. Resolve the issues above or re-run with --force.");
}
if (problems.length > 0 && opts.force) {
for (const problem of problems) {
p.log.warning(`Overridden by --force: ${problem}`);
}
}
// ── 3. Clean up (idempotent steps) ──────────────────────────────────
// 3a. Remove the git worktree registration
if (linkedWorktree) {
const worktreeDirExists = existsSync(linkedWorktree.worktree);
const spinner = p.spinner();
if (worktreeDirExists) {
spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`);
try {
const removeArgs = ["worktree", "remove", linkedWorktree.worktree];
if (opts.force) removeArgs.push("--force");
execFileSync("git", removeArgs, {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`);
} catch (error) {
spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
} else {
spinner.start("Pruning stale worktree entry...");
execFileSync("git", ["worktree", "prune"], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop("Pruned stale worktree entry.");
}
} else {
// Even without a linked worktree, prune to clean up any orphaned entries
execFileSync("git", ["worktree", "prune"], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
}
// 3b. Remove the worktree directory if it still exists (e.g. partial creation)
if (existsSync(targetPath)) {
const spinner = p.spinner();
spinner.start(`Removing worktree directory ${targetPath}...`);
rmSync(targetPath, { recursive: true, force: true });
spinner.stop(`Removed worktree directory ${targetPath}.`);
}
// 3c. Delete the local branch (now safe — worktree is gone)
if (localBranchExists(sourceCwd, name)) {
const spinner = p.spinner();
spinner.start(`Deleting local branch "${name}"...`);
try {
const deleteFlag = opts.force ? "-D" : "-d";
execFileSync("git", ["branch", deleteFlag, name], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Deleted local branch "${name}".`);
} catch (error) {
spinner.stop(pc.yellow(`Could not delete branch "${name}".`));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
}
// 3d. Remove instance data
if (existsSync(instanceRoot)) {
const spinner = p.spinner();
spinner.start(`Removing instance data at ${instanceRoot}...`);
rmSync(instanceRoot, { recursive: true, force: true });
spinner.stop(`Removed instance data at ${instanceRoot}.`);
}
p.outro(pc.green("Cleanup complete."));
}
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const envPath = resolvePaperclipEnvFile(configPath);
@@ -825,10 +1070,10 @@ export function registerWorktreeCommands(program: Command): void {
program
.command("worktree:make")
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
@@ -844,7 +1089,7 @@ export function registerWorktreeCommands(program: Command): void {
.description("Create repo-local config/env and an isolated instance for this worktree")
.option("--name <name>", "Display name used to derive the instance id")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
@@ -861,4 +1106,13 @@ export function registerWorktreeCommands(program: Command): void {
.option("-c, --config <path>", "Path to config file")
.option("--json", "Print JSON instead of shell exports")
.action(worktreeEnvCommand);
program
.command("worktree:cleanup")
.description("Safely remove a worktree, its branch, and its isolated instance data")
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed")
.option("--instance <id>", "Explicit instance id (if different from the worktree name)")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false)
.action(worktreeCleanupCommand);
}

View File

@@ -19,9 +19,9 @@ Current implementation status:
GitHub Actions owns `pnpm-lock.yaml`.
- Same-repo pull requests that change dependency manifests are auto-refreshed by GitHub Actions before merge.
- Fork pull requests that change dependency manifests must include the refreshed `pnpm-lock.yaml`.
- Pull request CI validates lockfile freshness when manifests change and verifies with `--frozen-lockfile`.
- Do not commit `pnpm-lock.yaml` in pull requests.
- Pull request CI validates dependency resolution when manifests change.
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
## Start Dev
@@ -152,7 +152,13 @@ Seed modes:
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
That repo-local env also sets:
- `PAPERCLIP_IN_WORKTREE=true`
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
Print shell exports explicitly when needed:

View File

@@ -94,3 +94,53 @@ Canonical mode design and command expectations live in `doc/DEPLOYMENT-MODES.md`
## Further Detail
See [SPEC.md](./SPEC.md) for the full technical specification and [TASKS.md](./TASKS.md) for the task management data model.
---
Paperclips core identity is a **control plane for autonomous AI companies**, centered on **companies, org charts, goals, issues/comments, heartbeats, budgets, approvals, and board governance**. The public docs are also explicit about the current boundaries: **tasks/comments are the built-in communication model**, Paperclip is **not a chatbot**, and it is **not a code review tool**. The roadmap already points toward **easier onboarding, cloud agents, easier agent configuration, plugins, better docs, and ClipMart/ClipHub-style reusable companies/templates**.
## What Paperclip should do vs. not do
**Do**
- Stay **board-level and company-level**. Users should manage goals, orgs, budgets, approvals, and outputs.
- Make the first five minutes feel magical: install, answer a few questions, see a CEO do something real.
- Keep work anchored to **issues/comments/projects/goals**, even if the surface feels conversational.
- Treat **agency / internal team / startup** as the same underlying abstraction with different templates and labels.
- Make outputs first-class: files, docs, reports, previews, links, screenshots.
- Provide **hooks into engineering workflows**: worktrees, preview servers, PR links, external review tools.
- Use **plugins** for edge cases like rich chat, knowledge bases, doc editors, custom tracing.
**Do not**
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. The current V1 spec still treats multi-board governance and fine-grained human permissions as out of scope, so the first multi-user version should be coarse and company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
## Specific design goals
1. **Time-to-first-success under 5 minutes**
A fresh user should go from install to “my CEO completed a first task” in one sitting.
2. **Board-level abstraction always wins**
The default UI should answer: what is the company doing, who is doing it, why does it matter, what did it cost, and what needs my approval.
3. **Conversation stays attached to work objects**
“Chat with CEO” should still resolve to strategy threads, decisions, tasks, or approvals.
4. **Progressive disclosure**
Top layer: human-readable summary. Middle layer: checklist/steps/artifacts. Bottom layer: raw logs/tool calls/transcript.
5. **Output-first**
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
6. **Local-first, cloud-ready**
The mental model should not change between local solo use and shared/private or public/cloud deployment.
7. **Safe autonomy**
Auto mode is allowed; hidden token burn is not.
8. **Thin core, rich edges**
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.

View File

@@ -0,0 +1,397 @@
# Token Optimization Plan
Date: 2026-03-13
Related discussion: https://github.com/paperclipai/paperclip/discussions/449
## Goal
Reduce token consumption materially without reducing agent capability, control-plane visibility, or task completion quality.
This plan is based on:
- the current V1 control-plane design
- the current adapter and heartbeat implementation
- the linked user discussion
- local runtime data from the default Paperclip instance on 2026-03-13
## Executive Summary
The discussion is directionally right about two things:
1. We should preserve session and prompt-cache locality more aggressively.
2. We should separate stable startup instructions from per-heartbeat dynamic context.
But that is not enough on its own.
After reviewing the code and local run data, the token problem appears to have four distinct causes:
1. **Measurement inflation on sessioned adapters.** Some token counters, especially for `codex_local`, appear to be recorded as cumulative session totals instead of per-heartbeat deltas.
2. **Avoidable session resets.** Task sessions are intentionally reset on timer wakes and manual wakes, which destroys cache locality for common heartbeat paths.
3. **Repeated context reacquisition.** The `paperclip` skill tells agents to re-fetch assignments, issue details, ancestors, and full comment threads on every heartbeat. The API does not currently offer efficient delta-oriented alternatives.
4. **Large static instruction surfaces.** Agent instruction files and globally injected skills are reintroduced at startup even when most of that content is unchanged and not needed for the current task.
The correct approach is:
1. fix telemetry so we can trust the numbers
2. preserve reuse where it is safe
3. make context retrieval incremental
4. add session compaction/rotation so long-lived sessions do not become progressively more expensive
## Validated Findings
### 1. Token telemetry is at least partly overstated today
Observed from the local default instance:
- `heartbeat_runs`: 11,360 runs between 2026-02-18 and 2026-03-13
- summed `usage_json.inputTokens`: `2,272,142,368,952`
- summed `usage_json.cachedInputTokens`: `2,217,501,559,420`
Those totals are not credible as true per-heartbeat usage for the observed prompt sizes.
Supporting evidence:
- `adapter.invoke.payload.prompt` averages were small:
- `codex_local`: ~193 chars average, 6,067 chars max
- `claude_local`: ~160 chars average, 1,160 chars max
- despite that, many `codex_local` runs report millions of input tokens
- one reused Codex session in local data spans 3,607 runs and recorded `inputTokens` growing up to `1,155,283,166`
Interpretation:
- for sessioned adapters, especially Codex, we are likely storing usage reported by the runtime as a **session total**, not a **per-run delta**
- this makes trend reporting, optimization work, and customer trust worse
This does **not** mean there is no real token problem. It means we need a trustworthy baseline before we can judge optimization impact.
### 2. Timer wakes currently throw away reusable task sessions
In `server/src/services/heartbeat.ts`, `shouldResetTaskSessionForWake(...)` returns `true` for:
- `wakeReason === "issue_assigned"`
- `wakeSource === "timer"`
- manual on-demand wakes
That means many normal heartbeats skip saved task-session resume even when the workspace is stable.
Local data supports the impact:
- `timer/system` runs: 6,587 total
- only 976 had a previous session
- only 963 ended with the same session
So timer wakes are the largest heartbeat path and are mostly not resuming prior task state.
### 3. We repeatedly ask agents to reload the same task context
The `paperclip` skill currently tells agents to do this on essentially every heartbeat:
- fetch assignments
- fetch issue details
- fetch ancestor chain
- fetch full issue comments
Current API shape reinforces that pattern:
- `GET /api/issues/:id/comments` returns the full thread
- there is no `since`, cursor, digest, or summary endpoint for heartbeat consumption
- `GET /api/issues/:id` returns full enriched issue context, not a minimal delta payload
This is safe but expensive. It forces the model to repeatedly consume unchanged information.
### 4. Static instruction payloads are not separated cleanly from dynamic heartbeat prompts
The user discussion suggested a bootstrap prompt. That is the right direction.
Current state:
- the UI exposes `bootstrapPromptTemplate`
- adapter execution paths do not currently use it
- several adapters prepend `instructionsFilePath` content directly into the per-run prompt or system prompt
Result:
- stable instructions are re-sent or re-applied in the same path as dynamic heartbeat content
- we are not deliberately optimizing for provider prompt caching
### 5. We inject more skill surface than most agents need
Local adapters inject repo skills into runtime skill directories.
Important `codex_local` nuance:
- Codex does not read skills directly from the active worktree.
- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`.
- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it.
- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land.
- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested.
Current repo skill sizes:
- `skills/paperclip/SKILL.md`: 17,441 bytes
- `.agents/skills/create-agent-adapter/SKILL.md`: 31,832 bytes
- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes
- `skills/para-memory-files/SKILL.md`: 3,978 bytes
That is nearly 58 KB of skill markdown before any company-specific instructions.
Not all of that is necessarily loaded into model context every run, but it increases startup surface area and should be treated as a token budget concern.
## Principles
We should optimize tokens under these rules:
1. **Do not lose functionality.** Agents must still be able to resume work safely, understand why tasks exist, and act within governance rules.
2. **Prefer stable context over repeated context.** Unchanged instructions should not be resent through the most expensive path.
3. **Prefer deltas over full reloads.** Heartbeats should consume only what changed since the last useful run.
4. **Measure normalized deltas, not raw adapter claims.** Especially for sessioned CLIs.
5. **Keep escape hatches.** Board/manual runs may still want a forced fresh session.
## Plan
## Phase 1: Make token telemetry trustworthy
This should happen first.
### Changes
- Store both:
- raw adapter-reported usage
- Paperclip-normalized per-run usage
- For sessioned adapters, compute normalized deltas against prior usage for the same persisted session.
- Add explicit fields for:
- `sessionReused`
- `taskSessionReused`
- `promptChars`
- `instructionsChars`
- `hasInstructionsFile`
- `skillSetHash` or skill count
- `contextFetchMode` (`full`, `delta`, `summary`)
- Add per-adapter parser tests that distinguish cumulative-session counters from per-run counters.
### Why
Without this, we cannot tell whether a reduction came from a real optimization or a reporting artifact.
### Success criteria
- per-run token totals stop exploding on long-lived sessions
- a resumed sessions usage curve is believable and monotonic at the session level, but not double-counted at the run level
- cost pages can show both raw and normalized numbers while we migrate
## Phase 2: Preserve safe session reuse by default
This is the highest-leverage behavior change.
### Changes
- Stop resetting task sessions on ordinary timer wakes.
- Keep resetting on:
- explicit manual “fresh run” invocations
- assignment changes
- workspace mismatch
- model mismatch / invalid resume errors
- Add an explicit wake flag like `forceFreshSession: true` when the board wants a reset.
- Record why a session was reused or reset in run metadata.
### Why
Timer wakes are the dominant heartbeat path. Resetting them destroys both session continuity and prompt cache reuse.
### Success criteria
- timer wakes resume the prior task session in the large majority of stable-workspace cases
- no increase in stale-session failures
- lower normalized input tokens per timer heartbeat
## Phase 3: Separate static bootstrap context from per-heartbeat context
This is the right version of the discussions bootstrap idea.
### Changes
- Implement `bootstrapPromptTemplate` in adapter execution paths.
- Use it only when starting a fresh session, not on resumed sessions.
- Keep `promptTemplate` intentionally small and stable:
- who I am
- what triggered this wake
- which task/comment/approval to prioritize
- Move long-lived setup text out of recurring per-run prompts where possible.
- Add UI guidance and warnings when `promptTemplate` contains high-churn or large inline content.
### Why
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent.
### Success criteria
- fresh-session prompts can remain richer without inflating every resumed heartbeat
- resumed prompts become short and structurally stable
- cache hit rates improve for session-preserving adapters
## Phase 4: Make issue/task context incremental
This is the biggest product change and likely the biggest real token saver after session reuse.
### Changes
Add heartbeat-oriented endpoints and skill behavior:
- `GET /api/agents/me/inbox-lite`
- minimal assignment list
- issue id, identifier, status, priority, updatedAt, lastExternalCommentAt
- `GET /api/issues/:id/heartbeat-context`
- compact issue state
- parent-chain summary
- latest execution summary
- change markers
- `GET /api/issues/:id/comments?after=<cursor>` or `?since=<timestamp>`
- return only new comments
- optional `GET /api/issues/:id/context-digest`
- server-generated compact summary for heartbeat use
Update the `paperclip` skill so the default pattern becomes:
1. fetch compact inbox
2. fetch compact task context
3. fetch only new comments unless this is the first read, a mention-triggered wake, or a cache miss
4. fetch full thread only on demand
### Why
Today we are using full-fidelity board APIs as heartbeat APIs. That is convenient but token-inefficient.
### Success criteria
- after first task acquisition, most heartbeats consume only deltas
- repeated blocked-task or long-thread work no longer replays the whole comment history
- mention-triggered wakes still have enough context to respond correctly
## Phase 5: Add session compaction and controlled rotation
This protects against long-lived session bloat.
### Changes
- Add rotation thresholds per adapter/session:
- turns
- normalized input tokens
- age
- cache hit degradation
- Before rotating, produce a structured carry-forward summary:
- current objective
- work completed
- open decisions
- blockers
- files/artifacts touched
- next recommended action
- Persist that summary in task session state or runtime state.
- Start the next session with:
- bootstrap prompt
- compact carry-forward summary
- current wake trigger
### Why
Even when reuse is desirable, some sessions become too expensive to keep alive indefinitely.
### Success criteria
- very long sessions stop growing without bound
- rotating a session does not cause loss of task continuity
- successful task completion rate stays flat or improves
## Phase 6: Reduce unnecessary skill surface
### Changes
- Move from “inject all repo skills” to an allowlist per agent or per adapter.
- Default local runtime skill set should likely be:
- `paperclip`
- Add opt-in skills for specialized agents:
- `paperclip-create-agent`
- `para-memory-files`
- `create-agent-adapter`
- Expose active skill set in agent config and run metadata.
- For `codex_local`, either:
- run with a worktree-specific `CODEX_HOME`, or
- treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout
### Why
Most agents do not need adapter-authoring or memory-system skills on every run.
### Success criteria
- smaller startup instruction surface
- no loss of capability for specialist agents that explicitly need extra skills
## Rollout Order
Recommended order:
1. telemetry normalization
2. timer-wake session reuse
3. bootstrap prompt implementation
4. heartbeat delta APIs + `paperclip` skill rewrite
5. session compaction/rotation
6. skill allowlists
## Acceptance Metrics
We should treat this plan as successful only if we improve both efficiency and task outcomes.
Primary metrics:
- normalized input tokens per successful heartbeat
- normalized input tokens per completed issue
- cache-hit ratio for sessioned adapters
- session reuse rate by invocation source
- fraction of heartbeats that fetch full comment threads
Guardrail metrics:
- task completion rate
- blocked-task rate
- stale-session failure rate
- manual intervention rate
- issue reopen rate after agent completion
Initial targets:
- 30% to 50% reduction in normalized input tokens per successful resumed heartbeat
- 80%+ session reuse on stable timer wakes
- 80%+ reduction in full-thread comment reloads after first task read
- no statistically meaningful regression in completion rate or failure rate
## Concrete Engineering Tasks
1. Add normalized usage fields and migration support for run analytics.
2. Patch sessioned adapter accounting to compute deltas from prior session totals.
3. Change `shouldResetTaskSessionForWake(...)` so timer wakes do not reset by default.
4. Implement `bootstrapPromptTemplate` end-to-end in adapter execution.
5. Add compact heartbeat context and incremental comment APIs.
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
7. Add session rotation with carry-forward summaries.
8. Replace global skill injection with explicit allowlists.
9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime.
## Recommendation
Treat this as a two-track effort:
- **Track A: correctness and no-regret wins**
- telemetry normalization
- timer-wake session reuse
- bootstrap prompt implementation
- **Track B: structural token reduction**
- delta APIs
- skill rewrite
- session compaction
- skill allowlists
If we only do Track A, we will improve things, but agents will still re-read too much unchanged task context.
If we only do Track B without fixing telemetry first, we will not be able to prove the gains cleanly.

View File

@@ -0,0 +1,775 @@
# Agent Evals Framework Plan
Date: 2026-03-13
## Context
We need evals for the thing Paperclip actually ships:
- agent behavior produced by adapter config
- prompt templates and bootstrap prompts
- skill sets and skill instructions
- model choice
- runtime policy choices that affect outcomes and cost
We do **not** primarily need a fine-tuning pipeline.
We need a regression framework that can answer:
- if we change prompts or skills, do agents still do the right thing?
- if we switch models, what got better, worse, or more expensive?
- if we optimize tokens, did we preserve task outcomes?
- can we grow the suite over time from real Paperclip usage?
This plan is based on:
- `doc/GOAL.md`
- `doc/PRODUCT.md`
- `doc/SPEC-implementation.md`
- `docs/agents-runtime.md`
- `doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
- Discussion #449: <https://github.com/paperclipai/paperclip/discussions/449>
- OpenAI eval best practices: <https://developers.openai.com/api/docs/guides/evaluation-best-practices>
- Promptfoo docs: <https://www.promptfoo.dev/docs/configuration/test-cases/> and <https://www.promptfoo.dev/docs/providers/custom-api/>
- LangSmith complex agent eval docs: <https://docs.langchain.com/langsmith/evaluate-complex-agent>
- Braintrust dataset/scorer docs: <https://www.braintrust.dev/docs/annotate/datasets> and <https://www.braintrust.dev/docs/evaluate/write-scorers>
## Recommendation
Paperclip should take a **two-stage approach**:
1. **Start with Promptfoo now** for narrow, prompt-and-skill behavior evals across models.
2. **Grow toward a first-party, repo-local eval harness in TypeScript** for full Paperclip scenario evals.
So the recommendation is no longer “skip Promptfoo.” It is:
- use Promptfoo as the fastest bootstrap layer
- keep eval cases and fixtures in this repo
- avoid making Promptfoo config the deepest long-term abstraction
More specifically:
1. The canonical eval definitions should live in this repo under a top-level `evals/` directory.
2. `v0` should use Promptfoo to run focused test cases across models and providers.
3. The longer-term harness should run **real Paperclip scenarios** against seeded companies/issues/agents, not just raw prompt completions.
4. The scoring model should combine:
- deterministic checks
- structured rubric scoring
- pairwise candidate-vs-baseline judging
- efficiency metrics from normalized usage/cost telemetry
5. The framework should compare **bundles**, not just models.
A bundle is:
- adapter type
- model id
- prompt template(s)
- bootstrap prompt template
- skill allowlist / skill content version
- relevant runtime flags
That is the right unit because that is what actually changes behavior in Paperclip.
## Why This Is The Right Shape
### 1. We need to evaluate system behavior, not only prompt output
Prompt-only tools are useful, but Paperclips real failure modes are often:
- wrong issue chosen
- wrong API call sequence
- bad delegation
- failure to respect approval boundaries
- stale session behavior
- over-reading context
- claiming completion without producing artifacts or comments
Those are control-plane behaviors. They require scenario setup, execution, and trace inspection.
### 2. The repo is already TypeScript-first
The existing monorepo already uses:
- `pnpm`
- `tsx`
- `vitest`
- TypeScript across server, UI, shared contracts, and adapters
A TypeScript-first harness will fit the repo and CI better than introducing a Python-first test subsystem as the default path.
Python can stay optional later for specialty scorers or research experiments.
### 3. We need provider/model comparison without vendor lock-in
OpenAIs guidance is directionally right:
- eval early and often
- use task-specific evals
- log everything
- prefer pairwise/comparison-style judging over open-ended scoring
But OpenAIs Evals API is not the right control plane for Paperclip as the primary system because our target is explicitly multi-model and multi-provider.
### 4. Hosted eval products are useful, and Promptfoo is the right bootstrap tool
The current tradeoff:
- Promptfoo is very good for local, repo-based prompt/provider matrices and CI integration.
- LangSmith is strong on trajectory-style agent evals.
- Braintrust has a clean dataset + scorer + experiment model and strong TypeScript support.
The community suggestion is directionally right:
- Promptfoo lets us start small
- it supports simple assertions like contains / not-contains / regex / custom JS
- it can run the same cases across multiple models
- it supports OpenRouter
- it can move into CI later
That makes it the best `v0` tool for “did this prompt/skill/model change obviously regress?”
But Paperclip should still avoid making a hosted platform or a third-party config format the core abstraction before we have our own stable eval model.
The right move is:
- start with Promptfoo for quick wins
- keep the data portable and repo-owned
- build a thin first-party harness around Paperclip concepts as the system grows
- optionally export to or integrate with other tools later if useful
## What We Should Evaluate
We should split evals into four layers.
### Layer 1: Deterministic contract evals
These should require no judge model.
Examples:
- agent comments on the assigned issue
- no mutation outside the agents company
- approval-required actions do not bypass approval flow
- task transitions are legal
- output contains required structured fields
- artifact links exist when the task required an artifact
- no full-thread refetch on delta-only cases once the API supports it
These are cheap, reliable, and should be the first line of defense.
### Layer 2: Single-step behavior evals
These test narrow behaviors in isolation.
Examples:
- chooses the correct issue from inbox
- writes a reasonable first status comment
- decides to ask for approval instead of acting directly
- delegates to the correct report
- recognizes blocked state and reports it clearly
These are the closest thing to prompt evals, but still framed in Paperclip terms.
### Layer 3: End-to-end scenario evals
These run a full heartbeat or short sequence of heartbeats against a seeded scenario.
Examples:
- new assignment pickup
- long-thread continuation
- mention-triggered clarification
- approval-gated hire request
- manager escalation
- workspace coding task that must leave a meaningful issue update
These should evaluate both final state and trace quality.
### Layer 4: Efficiency and regression evals
These are not “did the answer look good?” evals. They are “did we preserve quality while improving cost/latency?” evals.
Examples:
- normalized input tokens per successful heartbeat
- normalized tokens per completed issue
- session reuse rate
- full-thread reload rate
- wall-clock duration
- cost per successful scenario
This layer is especially important for token optimization work.
## Core Design
## 1. Canonical object: `EvalCase`
Each eval case should define:
- scenario setup
- target bundle(s)
- execution mode
- expected invariants
- scoring rubric
- tags/metadata
Suggested shape:
```ts
type EvalCase = {
id: string;
description: string;
tags: string[];
setup: {
fixture: string;
agentId: string;
trigger: "assignment" | "timer" | "on_demand" | "comment" | "approval";
};
inputs?: Record<string, unknown>;
checks: {
hard: HardCheck[];
rubric?: RubricCheck[];
pairwise?: PairwiseCheck[];
};
metrics: MetricSpec[];
};
```
The important part is that the case is about a Paperclip scenario, not a standalone prompt string.
## 2. Canonical object: `EvalBundle`
Suggested shape:
```ts
type EvalBundle = {
id: string;
adapter: string;
model: string;
promptTemplate: string;
bootstrapPromptTemplate?: string;
skills: string[];
flags?: Record<string, string | number | boolean>;
};
```
Every comparison run should say which bundle was tested.
This avoids the common mistake of saying “model X is better” when the real change was model + prompt + skills + runtime behavior.
## 3. Canonical output: `EvalTrace`
We should capture a normalized trace for scoring:
- run ids
- prompts actually sent
- session reuse metadata
- issue mutations
- comments created
- approvals requested
- artifacts created
- token/cost telemetry
- timing
- raw outputs
The scorer layer should never need to scrape ad hoc logs.
## Scoring Framework
## 1. Hard checks first
Every eval should start with pass/fail checks that can invalidate the run immediately.
Examples:
- touched wrong company
- skipped required approval
- no issue update produced
- returned malformed structured output
- marked task done without required artifact
If a hard check fails, the scenario fails regardless of style or judge score.
## 2. Rubric scoring second
Rubric scoring should use narrow criteria, not vague “how good was this?” prompts.
Good rubric dimensions:
- task understanding
- governance compliance
- useful progress communication
- correct delegation
- evidence of completion
- concision / unnecessary verbosity
Each rubric should be a small 0-1 or 0-2 decision, not a mushy 1-10 scale.
## 3. Pairwise judging for candidate vs baseline
OpenAIs eval guidance is right that LLMs are better at discrimination than open-ended generation.
So for non-deterministic quality checks, the default pattern should be:
- run baseline bundle on the case
- run candidate bundle on the same case
- ask a judge model which is better on explicit criteria
- allow `baseline`, `candidate`, or `tie`
This is better than asking a judge for an absolute quality score with no anchor.
## 4. Efficiency scoring is separate
Do not bury efficiency inside a single blended quality score.
Record it separately:
- quality score
- cost score
- latency score
Then compute a summary decision such as:
- candidate is acceptable only if quality is non-inferior and efficiency is improved
That is much easier to reason about than one magic number.
## Suggested Decision Rule
For PR gating:
1. No hard-check regressions.
2. No significant regression on required scenario pass rate.
3. No significant regression on key rubric dimensions.
4. If the change is token-optimization-oriented, require efficiency improvement on target scenarios.
For deeper comparison reports, show:
- pass rate
- pairwise wins/losses/ties
- median normalized tokens
- median wall-clock time
- cost deltas
## Dataset Strategy
We should explicitly build the dataset from three sources.
### 1. Hand-authored seed cases
Start here.
These should cover core product invariants:
- assignment pickup
- status update
- blocked reporting
- delegation
- approval request
- cross-company access denial
- issue comment follow-up
These are small, clear, and stable.
### 2. Production-derived cases
Per OpenAIs guidance, we should log everything and mine real usage for eval cases.
Paperclip should grow eval coverage by promoting real runs into cases when we see:
- regressions
- interesting failures
- edge cases
- high-value success patterns worth preserving
The initial version can be manual:
- take a real run
- redact/normalize it
- convert it into an `EvalCase`
Later we can automate trace-to-case generation.
### 3. Adversarial and guardrail cases
These should intentionally probe failure modes:
- approval bypass attempts
- wrong-company references
- stale context traps
- irrelevant long threads
- misleading instructions in comments
- verbosity traps
This is where promptfoo-style red-team ideas can become useful later, but it is not the first slice.
## Repo Layout
Recommended initial layout:
```text
evals/
README.md
promptfoo/
promptfooconfig.yaml
prompts/
cases/
cases/
core/
approvals/
delegation/
efficiency/
fixtures/
companies/
issues/
bundles/
baseline/
experiments/
runners/
scenario-runner.ts
compare-runner.ts
scorers/
hard/
rubric/
pairwise/
judges/
rubric-judge.ts
pairwise-judge.ts
lib/
types.ts
traces.ts
metrics.ts
reports/
.gitignore
```
Why top-level `evals/`:
- it makes evals feel first-class
- it avoids hiding them inside `server/` even though they span adapters and runtime behavior
- it leaves room for both TS and optional Python helpers later
- it gives us a clean place for Promptfoo `v0` config plus the later first-party runner
## Execution Model
The harness should support three modes.
### Mode A: Cheap local smoke
Purpose:
- run on PRs
- keep cost low
- catch obvious regressions
Characteristics:
- 5 to 20 cases
- 1 or 2 bundles
- mostly hard checks and narrow rubrics
### Mode B: Candidate vs baseline compare
Purpose:
- evaluate a prompt/skill/model change before merge
Characteristics:
- paired runs
- pairwise judging enabled
- quality + efficiency diff report
### Mode C: Nightly broader matrix
Purpose:
- compare multiple models and bundles
- grow historical benchmark data
Characteristics:
- larger case set
- multiple models
- more expensive rubric/pairwise judging
## CI and Developer Workflow
Suggested commands:
```sh
pnpm evals:smoke
pnpm evals:compare --baseline baseline/codex-default --candidate experiments/codex-lean-skillset
pnpm evals:nightly
```
PR behavior:
- run `evals:smoke` on prompt/skill/adapter/runtime changes
- optionally trigger `evals:compare` for labeled PRs or manual runs
Nightly behavior:
- run larger matrix
- save report artifact
- surface trend lines on pass rate, pairwise wins, and efficiency
## Framework Comparison
## Promptfoo
Best use for Paperclip:
- prompt-level micro-evals
- provider/model comparison
- quick local CI integration
- custom JS assertions and custom providers
- bootstrap-layer evals for one skill or one agent workflow
What changed in this recommendation:
- Promptfoo is now the recommended **starting point**
- especially for “one skill, a handful of cases, compare across models”
Why it still should not be the only long-term system:
- its primary abstraction is still prompt/provider/test-case oriented
- Paperclip needs scenario setup, control-plane state inspection, and multi-step traces as first-class concepts
Recommendation:
- use Promptfoo first
- store Promptfoo config and cases in-repo under `evals/promptfoo/`
- use custom JS/TS assertions and, if needed later, a custom provider that calls Paperclip scenario runners
- do not make Promptfoo YAML the only canonical Paperclip eval format once we outgrow prompt-level evals
## LangSmith
What it gets right:
- final response evals
- trajectory evals
- single-step evals
Why not the primary system today:
- stronger fit for teams already centered on LangChain/LangGraph
- introduces hosted/external workflow gravity before our own eval model is stable
Recommendation:
- copy the trajectory/final/single-step taxonomy
- do not adopt the platform as the default requirement
## Braintrust
What it gets right:
- TypeScript support
- clean dataset/task/scorer model
- production logging to datasets
- experiment comparison over time
Why not the primary system today:
- still externalizes the canonical dataset and review workflow
- we are not yet at the maturity where hosted experiment management should define the shape of the system
Recommendation:
- borrow its dataset/scorer/experiment mental model
- revisit once we want hosted review and experiment history at scale
## OpenAI Evals / Evals API
What it gets right:
- strong eval principles
- emphasis on task-specific evals
- continuous evaluation mindset
Why not the primary system:
- Paperclip must compare across models/providers
- we do not want our primary eval runner coupled to one model vendor
Recommendation:
- use the guidance
- do not use it as the core Paperclip eval runtime
## First Implementation Slice
The first version should be intentionally small.
## Phase 0: Promptfoo bootstrap
Build:
- `evals/promptfoo/promptfooconfig.yaml`
- 5 to 10 focused cases for one skill or one agent workflow
- model matrix using the providers we care about most
- mostly deterministic assertions:
- contains
- not-contains
- regex
- custom JS assertions
Target scope:
- one skill, or one narrow workflow such as assignment pickup / first status update
- compare a small set of bundles across several models
Success criteria:
- we can run one command and compare outputs across models
- prompt/skill regressions become visible quickly
- the team gets signal before building heavier infrastructure
## Phase 1: Skeleton and core cases
Build:
- `evals/` scaffold
- `EvalCase`, `EvalBundle`, `EvalTrace` types
- scenario runner for seeded local cases
- 10 hand-authored core cases
- hard checks only
Target cases:
- assigned issue pickup
- write progress comment
- ask for approval when required
- respect company boundary
- report blocked state
- avoid marking done without artifact/comment evidence
Success criteria:
- a developer can run a local smoke suite
- prompt/skill changes can fail the suite deterministically
- Promptfoo `v0` cases either migrate into or coexist with this layer cleanly
## Phase 2: Pairwise and rubric layer
Build:
- rubric scorer interface
- pairwise judge runner
- candidate vs baseline compare command
- markdown/html report output
Success criteria:
- model/prompt bundle changes produce a readable diff report
- we can tell “better”, “worse”, or “same” on curated scenarios
## Phase 3: Efficiency integration
Build:
- normalized token/cost metrics into eval traces
- cost and latency comparisons
- efficiency gates for token optimization work
Dependency:
- this should align with the telemetry normalization work in `2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
Success criteria:
- quality and efficiency can be judged together
- token-reduction work no longer relies on anecdotal improvements
## Phase 4: Production-case ingestion
Build:
- tooling to promote real runs into new eval cases
- metadata tagging
- failure corpus growth process
Success criteria:
- the eval suite grows from real product behavior instead of staying synthetic
## Initial Case Categories
We should start with these categories:
1. `core.assignment_pickup`
2. `core.progress_update`
3. `core.blocked_reporting`
4. `governance.approval_required`
5. `governance.company_boundary`
6. `delegation.correct_report`
7. `threads.long_context_followup`
8. `efficiency.no_unnecessary_reloads`
That is enough to start catching the classes of regressions we actually care about.
## Important Guardrails
### 1. Do not rely on judge models alone
Every important scenario needs deterministic checks first.
### 2. Do not gate PRs on a single noisy score
Use pass/fail invariants plus a small number of stable rubric or pairwise checks.
### 3. Do not confuse benchmark score with product quality
The suite must keep growing from real runs, otherwise it will become a toy benchmark.
### 4. Do not evaluate only final output
Trajectory matters for agents:
- did they call the right Paperclip APIs?
- did they ask for approval?
- did they communicate progress?
- did they choose the right issue?
### 5. Do not make the framework vendor-shaped
Our eval model should survive changes in:
- judge provider
- candidate provider
- adapter implementation
- hosted tooling choices
## Open Questions
1. Should the first scenario runner invoke the real server over HTTP, or call services directly in-process?
My recommendation: start in-process for speed, then add HTTP-mode coverage once the model stabilizes.
2. Should we support Python scorers in v1?
My recommendation: no. Keep v1 all-TypeScript.
3. Should we commit baseline outputs?
My recommendation: commit case definitions and bundle definitions, but keep run artifacts out of git.
4. Should we add hosted experiment tracking immediately?
My recommendation: no. Revisit after the local harness proves useful.
## Final Recommendation
Start with Promptfoo for immediate, narrow model-and-prompt comparisons, then grow into a first-party `evals/` framework in TypeScript that evaluates **Paperclip scenarios and bundles**, not just prompts.
Use this structure:
- Promptfoo for `v0` bootstrap
- deterministic hard checks as the foundation
- rubric and pairwise judging for non-deterministic quality
- normalized efficiency metrics as a separate axis
- repo-local datasets that grow from real runs
Use external tools selectively:
- Promptfoo as the initial path for narrow prompt/provider tests
- Braintrust or LangSmith later if we want hosted experiment management
But keep the canonical eval model inside the Paperclip repo and aligned to Paperclips actual control-plane behaviors.

View File

@@ -0,0 +1,780 @@
# Feature specs
## 1) Guided onboarding + first-job magic
The repo already has `onboard`, `doctor`, `run`, deployment modes, and even agent-oriented onboarding text/skills endpoints, but there are also current onboarding/auth validation issues and an open “onboard failed” report. That means this is not just polish; it is product-critical. ([GitHub][1])
### Product decision
Replace “configuration-first onboarding” with **interview-first onboarding**.
### What we want
- Ask 34 questions up front, not 20 settings.
- Generate the right path automatically: local solo, shared private, or public cloud.
- Detect what agent/runtime environment already exists.
- Make it normal to have Claude/OpenClaw/Codex help complete setup.
- End onboarding with a **real first task**, not a blank dashboard.
### What we do not want
- Provider jargon before value.
- “Go find an API key” as the default first instruction.
- A successful install that still leaves users unsure what to do next.
### Proposed UX
On first run, show an interview:
```ts
type OnboardingProfile = {
useCase: "startup" | "agency" | "internal_team";
companySource: "new" | "existing";
deployMode: "local_solo" | "shared_private" | "shared_public";
autonomyMode: "hands_on" | "hybrid" | "full_auto";
primaryRuntime: "claude_code" | "codex" | "openclaw" | "other";
};
```
Questions:
1. What are you building?
2. Is this a new company, an existing company, or a service/agency team?
3. Are you working solo on one machine, sharing privately with a team, or deploying publicly?
4. Do you want full auto, hybrid, or tight manual control?
Then Paperclip should:
- detect installed CLIs/providers/subscriptions
- recommend the matching deployment/auth mode
- generate a local `onboarding.txt` / LLM handoff prompt
- offer a button: **“Open this in Claude / copy setup prompt”**
- create starter objects:
- company
- company goal
- CEO
- founding engineer or equivalent first report
- first suggested task
### Backend / API
- Add `GET /api/onboarding/recommendation`
- Add `GET /api/onboarding/llm-handoff.txt`
- Reuse existing invite/onboarding/skills patterns for local-first bootstrap
- Persist onboarding answers into instance config for later defaults
### Acceptance criteria
- Fresh install with a supported local runtime completes without manual JSON/env editing.
- User sees first live agent action before leaving onboarding.
- A blank dashboard is no longer the default post-install state.
- If a required dependency is missing, the error is prescriptive and fixable from the UI/CLI.
### Non-goals
- Account creation
- enterprise SSO
- perfect provider auto-detection for every runtime
---
## 2) Board command surface, not generic chat
There is a real tension here: the transcript says users want “chat with my CEO,” while the public product definition says Paperclip is **not a chatbot** and V1 communication is **tasks + comments only**. At the same time, the repo is already exploring plugin infrastructure and even a chat plugin via plugin SSE streaming. The clean resolution is: **make the core surface conversational, but keep the data model task/thread-centric; reserve full chat as an optional plugin**. ([GitHub][2])
### Product decision
Build a **Command Composer** backed by issues/comments/approvals, not a separate chat subsystem.
### What we want
- “Talk to the CEO” feeling for the user.
- Every conversation ends up attached to a real company object.
- Strategy discussion can produce issues, artifacts, and approvals.
### What we do not want
- A blank “chat with AI” home screen disconnected from the org.
- Yet another agent-chat product.
### Proposed UX
Add a global composer with modes:
```ts
type ComposerMode = "ask" | "task" | "decision";
type ThreadScope = "company" | "project" | "issue" | "agent";
```
Examples:
- On dashboard: “Ask the CEO for a hiring plan” → creates a `strategy` issue/thread scoped to the company.
- On agent page: “Tell the designer to make this cleaner” → appends an instruction comment to an issue or spawns a new delegated task.
- On approval page: “Why are you asking to hire?” → appends a board comment to the approval context.
Add issue kinds:
```ts
type IssueKind = "task" | "strategy" | "question" | "decision";
```
### Backend / data model
Prefer extending existing `issues` rather than creating `chats`:
- `issues.kind`
- `issues.scope`
- optional `issues.target_agent_id`
- comment metadata: `comment.intent = hint | correction | board_question | board_decision`
### Acceptance criteria
- A user can “ask CEO” from the dashboard and receive a response in a company-scoped thread.
- From that thread, the user can create/approve tasks with one click.
- No separate chat database is required for v1 of this feature.
### Non-goals
- consumer chat UX
- model marketplace
- general-purpose assistant unrelated to company context
---
## 3) Live org visibility + explainability layer
The core product promise is already visibility and governance, but right now the transcript makes clear that the UI is still too close to raw agent execution. The repo already has org charts, activity, heartbeat runs, costs, and agent detail surfaces; the missing piece is the explanatory layer above them. ([GitHub][1])
### Product decision
Default the UI to **human-readable operational summaries**, with raw logs one layer down.
### What we want
- At company level: “who is active, what are they doing, what is moving between teams”
- At agent level: “what is the plan, what step is complete, what outputs were produced”
- At run level: “summary first, transcript second”
### Proposed UX
Company page:
- org chart with live active-state indicators
- delegation animation between nodes when work moves
- current open priorities
- pending approvals
- burn / budget warning strip
Agent page:
- status card
- current issue
- plan checklist
- latest artifact(s)
- summary of last run
- expandable raw trace/logs
Run page:
- **Summary**
- **Steps**
- **Raw transcript / tool calls**
### Backend / API
Generate a run view model from current run/activity data:
```ts
type RunSummary = {
runId: string;
headline: string;
objective: string | null;
currentStep: string | null;
completedSteps: string[];
delegatedTo: { agentId: string; issueId?: string }[];
artifactIds: string[];
warnings: string[];
};
```
Phase 1 can derive this server-side from existing run logs/comments. Persist only if needed later.
### Acceptance criteria
- Board can tell what is happening without reading shell commands.
- Raw logs are still accessible, but not the default surface.
- First task / first hire / first completion moments are visibly celebrated.
### Non-goals
- overdesigned animation system
- perfect semantic summarization before core data quality exists
---
## 4) Artifact system: attachments, file browser, previews
This gap is already showing up in the repo. Storage is present, attachment endpoints exist, but current issues show that attachments are still effectively image-centric and comment attachment rendering is incomplete. At the same time, your transcript wants plans, docs, files, and generated web pages surfaced cleanly. ([GitHub][4])
### Product decision
Introduce a first-class **Artifact** model that unifies:
- uploaded/generated files
- workspace files of interest
- preview URLs
- generated docs/reports
### What we want
- Plans, specs, CSVs, markdown, PDFs, logs, JSON, HTML outputs
- easy discoverability from the issue/run/company pages
- a lightweight file browser for project workspaces
- preview links for generated websites/apps
### What we do not want
- forcing agents to paste everything inline into comments
- HTML stuffed into comment bodies as a workaround
- a full web IDE
### Phase 1: fix the obvious gaps
- Accept non-image MIME types for issue attachments
- Attach files to comments correctly
- Show file metadata + download/open on issue page
### Phase 2: introduce artifacts
```ts
type ArtifactKind = "attachment" | "workspace_file" | "preview" | "report_link";
interface Artifact {
id: string;
companyId: string;
issueId?: string;
runId?: string;
agentId?: string;
kind: ArtifactKind;
title: string;
mimeType?: string;
filename?: string;
sizeBytes?: number;
storageKind: "local_disk" | "s3" | "external_url";
contentPath?: string;
previewUrl?: string;
metadata: Record<string, unknown>;
}
```
### UX
Issue page gets a **Deliverables** section:
- Files
- Reports
- Preview links
- Latest generated artifact highlighted at top
Project page gets a **Files** tab:
- folder tree
- recent changes
- “Open produced files” shortcut
### Preview handling
For HTML/static outputs:
- local deploy → open local preview URL
- shared/public deploy → host via configured preview service or static storage
- preview URL is registered back onto the issue as an artifact
### Acceptance criteria
- Agents can attach `.md`, `.txt`, `.json`, `.csv`, `.pdf`, and `.html`.
- Users can open/download them from the issue page.
- A generated static site can be opened from an issue without hunting through the filesystem.
### Non-goals
- browser IDE
- collaborative docs editor
- full object-storage admin UI
---
## 5) Shared/cloud deployment + cloud runtimes
The repo already has a clear deployment story in docs: `local_trusted`, `authenticated/private`, and `authenticated/public`, plus Tailscale guidance. The roadmap explicitly calls out cloud agents like Cursor / e2b. That means the next step is not inventing a deployment model; it is making the shared/cloud path canonical and production-usable. ([GitHub][5])
### Product decision
Make **shared/private deploy** and **public/cloud deploy** first-class supported modes, and add **remote runtime drivers** for cloud-executed agents.
### What we want
- one instance a team can actually share
- local-first path that upgrades to private/public without a mental model change
- remote agent execution for non-local runtimes
### Proposed architecture
Separate **control plane** from **execution runtime** more explicitly:
```ts
type RuntimeDriver = "local_process" | "remote_sandbox" | "webhook";
interface ExecutionHandle {
externalRunId: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
previewUrl?: string;
logsUrl?: string;
}
```
First remote driver: `remote_sandbox` for e2b-style execution.
### Deliverables
- canonical deploy recipes:
- local solo
- shared private (Tailscale/private auth)
- public cloud (managed Postgres + object storage + public URL)
- runtime health page
- adapter/runtime capability matrix
- one official reference deployment path
### UX
New “Deployment” settings page:
- instance mode
- auth/exposure
- storage/database status
- runtime drivers configured
- health and reachability checks
### Acceptance criteria
- Two humans can log into one authenticated/private instance and use it concurrently.
- A public deployment can run agents via at least one remote runtime.
- `doctor` catches missing public/private config and gives concrete fixes.
### Non-goals
- fully managed Paperclip SaaS
- every possible cloud provider in v1
---
## 6) Multi-human collaboration (minimal, not enterprise RBAC)
This is the biggest deliberate departure from the current V1 spec. Publicly, V1 still says “single human board operator” and puts role-based human granularity out of scope. But the transcript is right that shared use is necessary if Paperclip is going to be real for teams. The key is to do a **minimal collaboration model**, not a giant permission system. ([GitHub][2])
### Product decision
Ship **coarse multi-user company memberships**, not fine-grained enterprise RBAC.
### Proposed roles
```ts
type CompanyRole = "owner" | "admin" | "operator" | "viewer";
```
- **owner**: instance/company ownership, user invites, config
- **admin**: manage org, agents, budgets, approvals
- **operator**: create/update issues, interact with agents, view artifacts
- **viewer**: read-only
### Data model
```ts
interface CompanyMembership {
userId: string;
companyId: string;
role: CompanyRole;
invitedByUserId: string;
createdAt: string;
}
```
Stretch goal later:
- optional project/team scoping
### What we want
- shared dashboard for real teams
- user attribution in activity log
- simple invite flow
- company-level isolation preserved
### What we do not want
- per-field ACLs
- SCIM/SSO/enterprise admin consoles
- ten permission toggles per page
### Acceptance criteria
- Team of 3 can use one shared Paperclip instance.
- Every user action is attributed correctly in activity.
- Company membership boundaries are enforced.
- Viewer cannot mutate; operator/admin can.
### Non-goals
- enterprise RBAC
- cross-company matrix permissions
- multi-board governance logic in first cut
---
## 7) Auto mode + interrupt/resume
This is a product behavior issue, not a UI nicety. If agents cannot keep working or accept course correction without restarting, the autonomy model feels fake.
### Product decision
Make auto mode and mid-run interruption first-class runtime semantics.
### What we want
- Auto mode that continues until blocked by approvals, budgets, or explicit pause.
- Mid-run “you missed this” correction without losing session continuity.
- Clear state when an agent is waiting, blocked, or paused.
### Proposed state model
```ts
type RunState =
| "queued"
| "running"
| "waiting_approval"
| "waiting_input"
| "paused"
| "completed"
| "failed"
| "cancelled";
```
Add board interjections as resumable input events:
```ts
interface RunMessage {
runId: string;
authorUserId: string;
mode: "hint" | "correction" | "hard_override";
body: string;
resumeCurrentSession: boolean;
}
```
### UX
Buttons on active run:
- Pause
- Resume
- Interrupt
- Abort
- Restart from scratch
Interrupt opens a small composer that explicitly says:
- continue current session
- or restart run
### Acceptance criteria
- A board comment can resume an active session instead of spawning a fresh one.
- Session ID remains stable for “continue” path.
- UI clearly distinguishes blocked vs. waiting vs. paused.
### Non-goals
- simultaneous multi-user live editing of the same run transcript
- perfect conversational UX before runtime semantics are fixed
---
## 8) Cost safety + heartbeat/runtime hardening
This is probably the most important immediate workstream. The transcript says token burn is the highest pain, and the repo currently has active issues around budget enforcement evidence, onboarding/auth validation, and circuit-breaker style waste prevention. Public docs already promise hard budgets, and the issue tracker is pointing at the missing operational protections. ([GitHub][6])
### Product decision
Treat this as a **P0 runtime contract**, not a nice-to-have.
### Part A: deterministic wake gating
Do cheap, explicit work detection before invoking an LLM.
```ts
type WakeReason =
| "new_assignment"
| "new_comment"
| "mention"
| "approval_resolved"
| "scheduled_scan"
| "manual";
```
Rules:
- if no new actionable input exists, do not call the model
- scheduled scan should be a cheap policy check first, not a full reasoning pass
### Part B: budget contract
Keep the existing public promise, but make it undeniable:
- warning at 80%
- auto-pause at 100%
- visible audit trail
- explicit board override to continue
### Part C: circuit breaker
Add per-agent runtime guards:
```ts
interface CircuitBreakerConfig {
enabled: boolean;
maxConsecutiveNoProgress: number;
maxConsecutiveFailures: number;
tokenVelocityMultiplier: number;
}
```
Trip when:
- no issue/status/comment progress for N runs
- N failures in a row
- token spike vs rolling average
### Part D: refactor heartbeat service
Split current orchestration into modules:
- wake detector
- checkout/lock manager
- adapter runner
- session manager
- cost recorder
- breaker evaluator
- event streamer
### Part E: regression suite
Mandatory automated proofs for:
- onboarding/auth matrix
- 80/100 budget behavior
- no cross-company auth leakage
- no-spurious-wake idle behavior
- active-run resume/interruption
- remote runtime smoke
### Acceptance criteria
- Idle org with no new work does not generate model calls from heartbeat scans.
- 80% shows warning only.
- 100% pauses the agent and blocks continued execution until override.
- Circuit breaker pause is visible in audit/activity.
- Runtime modules have explicit contracts and are testable independently.
### Non-goals
- perfect autonomous optimization
- eliminating all wasted calls in every adapter/provider
---
## 9) Project workspaces, previews, and PR handoff — without becoming GitHub
This is the right way to resolve the code-workflow debate. The repo already has worktree-local instances, project `workspaceStrategy.provisionCommand`, and an RFC for adapter-level git worktree isolation. That is the correct architectural direction: **project execution policies and workspace isolation**, not built-in PR review. ([GitHub][7])
### Product decision
Paperclip should manage the **issue → workspace → preview/PR → review handoff** lifecycle, but leave diffs/review/merge to external tools.
### Proposed config
Prefer repo-local project config:
```yaml
# .paperclip/project.yml
execution:
workspaceStrategy: shared | worktree | ephemeral_container
deliveryMode: artifact | preview | pull_request
provisionCommand: "pnpm install"
teardownCommand: "pnpm clean"
preview:
command: "pnpm dev --port $PAPERCLIP_PREVIEW_PORT"
healthPath: "/"
ttlMinutes: 120
vcs:
provider: github
repo: owner/repo
prPerIssue: true
baseBranch: main
```
### Rules
- For non-code projects: `deliveryMode=artifact`
- For UI/app work: `deliveryMode=preview`
- For git-backed engineering projects: `deliveryMode=pull_request`
- For git-backed projects with `prPerIssue=true`, one issue maps to one isolated branch/worktree
### UX
Issue page shows:
- workspace link/status
- preview URL if available
- PR URL if created
- “Reopen preview” button with TTL
- lifecycle:
- `todo`
- `in_progress`
- `in_review`
- `done`
### What we want
- safe parallel agent work on one repo
- previewable output
- external PR review
- project-defined hooks, not hardcoded assumptions
### What we do not want
- built-in diff viewer
- merge queue
- Jira clone
- mandatory PRs for non-code work
### Acceptance criteria
- Multiple engineer agents can work concurrently without workspace contamination.
- When a project is in PR mode, the issue contains branch/worktree/preview/PR metadata.
- Preview can be reopened on demand until TTL expires.
### Non-goals
- replacing GitHub/GitLab
- universal preview hosting for every framework on day one
---
## 10) Plugin system as the escape hatch
The roadmap already includes plugins, GitHub discussions are active around it, and there is an open issue proposing an SSE bridge specifically to enable streaming plugin UIs such as chat, logs, and monitors. This is exactly the right place for optional surfaces. ([GitHub][1])
### Product decision
Keep the control-plane core thin; put optional high-variance experiences into plugins.
### First-party plugin targets
- Chat
- Knowledge base / RAG
- Log tail / live build output
- Custom tracing or queues
- Doc editor / proposal builder
### Plugin manifest
```ts
interface PluginManifest {
id: string;
version: string;
requestedPermissions: (
| "read_company"
| "read_issue"
| "write_issue_comment"
| "create_issue"
| "stream_ui"
)[];
surfaces: ("company_home" | "issue_panel" | "agent_panel" | "sidebar")[];
workerEntry: string;
uiEntry: string;
}
```
### Platform requirements
- host ↔ worker action bridge
- SSE/UI streaming
- company-scoped auth
- permission declaration
- surface slots in UI
### Acceptance criteria
- A plugin can stream events to UI in real time.
- A chat plugin can converse without requiring chat to become the core Paperclip product.
- Plugin permissions are company-scoped and auditable.
### Non-goals
- plugins mutating core schema directly
- arbitrary privileged code execution without explicit permissions
---
## Priority order I would use
Given the repo state and the transcript, I would sequence it like this:
**P0**
1. Cost safety + heartbeat hardening
2. Guided onboarding + first-job magic
3. Shared/cloud deployment foundation
4. Artifact phase 1: non-image attachments + deliverables surfacing
**P1** 5. Board command surface 6. Visibility/explainability layer 7. Auto mode + interrupt/resume 8. Minimal multi-user collaboration
**P2** 9. Project workspace / preview / PR lifecycle 10. Plugin system + optional chat plugin 11. Template/preset expansion for startup vs agency vs internal-team onboarding
Why this order: the current repo is already getting pressure on onboarding failures, auth/onboarding validation, budget enforcement, and wasted token burn. If those are shaky, everything else feels impressive but unsafe. ([GitHub][3])
## Bottom line
The best synthesis is:
- **Keep** Paperclip as the board-level control plane.
- **Do not** make chat, code review, or workflow-building the core identity.
- **Do** make the product feel conversational, visible, output-oriented, and shared.
- **Do** make coding workflows an integration surface via workspaces/previews/PR links.
- **Use plugins** for richer edges like chat and knowledge.
That keeps the repos current product direction intact while solving almost every pain surfaced in the transcript.
### Key references
- README / positioning / roadmap / product boundaries. ([GitHub][1])
- Product definition. ([GitHub][8])
- V1 implementation spec and explicit non-goals. ([GitHub][2])
- Core concepts and architecture. ([GitHub][9])
- Deployment modes / Tailscale / local-to-cloud path. ([GitHub][5])
- Developing guide: worktree-local instances, provision hooks, onboarding endpoints. ([GitHub][7])
- Current issue pressure: onboarding failure, auth/onboarding validation, budget enforcement, circuit breaker, attachment gaps, plugin chat. ([GitHub][3])
[1]: https://github.com/paperclipai/paperclip "https://github.com/paperclipai/paperclip"
[2]: https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md "https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md"
[3]: https://github.com/paperclipai/paperclip/issues/704 "https://github.com/paperclipai/paperclip/issues/704"
[4]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md"
[5]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md"
[6]: https://github.com/paperclipai/paperclip/issues/692 "https://github.com/paperclipai/paperclip/issues/692"
[7]: https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md "https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md"
[8]: https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md "https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md"
[9]: https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md "https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md"

View File

@@ -0,0 +1,186 @@
# Paperclip Skill Tightening Plan
## Status
Deferred follow-up. Do not include in the current token-optimization PR beyond documenting the plan.
## Why This Is Deferred
The `paperclip` skill is part of the critical control-plane safety surface. Tightening it may reduce fresh-session token use, but it also carries prompt-regression risk. We do not yet have evals that would let us safely prove behavior preservation across assignment handling, checkout rules, comment etiquette, approval workflows, and escalation paths.
The current PR should ship the lower-risk infrastructure wins first:
- telemetry normalization
- safe session reuse
- incremental issue/comment context
- bootstrap versus heartbeat prompt separation
- Codex worktree isolation
## Current Problem
Fresh runs still spend substantial input tokens even after the context-path fixes. The remaining large startup cost appears to come from loading the full `paperclip` skill and related instruction surface into context at run start.
The skill currently mixes three kinds of content in one file:
- hot-path heartbeat procedure used on nearly every run
- critical policy and safety invariants
- rare workflow/reference material that most runs do not need
That structure is safe but expensive.
## Goals
- reduce first-run instruction tokens without weakening agent safety
- preserve all current Paperclip control-plane capabilities
- keep common heartbeat behavior explicit and easy for agents to follow
- move rare workflows and reference material out of the hot path
- create a structure that can later be evaluated systematically
## Non-Goals
- changing Paperclip API semantics
- removing required governance rules
- deleting rare workflows
- changing agent defaults in the current PR
## Recommended Direction
### 1. Split Hot Path From Lookup Material
Restructure the skill into:
- an always-loaded core section for the common heartbeat loop
- on-demand material for infrequent workflows and deep reference
The core should cover only what is needed on nearly every wake:
- auth and required headers
- inbox-first assignment retrieval
- mandatory checkout behavior
- `heartbeat-context` first
- incremental comment retrieval rules
- mention/self-assign exception
- blocked-task dedup
- status/comment/release expectations before exit
### 2. Normalize The Skill Around One Canonical Procedure
The same rules are currently expressed multiple times across:
- heartbeat steps
- critical rules
- endpoint reference
- workflow examples
Refactor so each operational fact has one primary home:
- procedure
- invariant list
- appendix/reference
This reduces prompt weight and lowers the chance of internal instruction drift.
### 3. Compress Prose Into High-Signal Instruction Forms
Rewrite the hot path using compact operational forms:
- short ordered checklist
- flat invariant list
- minimal examples only where ambiguity would be risky
Reduce:
- narrative explanation
- repeated warnings already covered elsewhere
- large example payloads for common operations
- long endpoint matrices in the main body
### 4. Move Rare Workflows Behind Explicit Triggers
These workflows should remain available but should not dominate fresh-run context:
- OpenClaw invite flow
- project setup flow
- planning `<plan/>` writeback flow
- instructions-path update flow
- detailed link-formatting examples
Recommended approach:
- keep a short pointer in the main skill
- move detailed procedures into sibling skills or referenced docs that agents read only when needed
### 5. Separate Policy From Reference
The skill should distinguish:
- mandatory operating rules
- endpoint lookup/reference
- business-process playbooks
That separation makes it easier to evaluate prompt changes later and lets adapters or orchestration choose what must always be loaded.
## Proposed Target Structure
1. Purpose and authentication
2. Compact heartbeat procedure
3. Hard invariants
4. Required comment/update style
5. Triggered workflow index
6. Appendix/reference
## Rollout Plan
### Phase 1. Inventory And Measure
- annotate the current skill by section and estimate token weight
- identify which sections are truly hot-path versus rare
- capture representative runs to compare before/after prompt size and behavior
### Phase 2. Structural Refactor Without Semantic Changes
- rewrite the main skill into the target structure
- preserve all existing rules and capabilities
- move rare workflow details into referenced companion material
- keep wording changes conservative
### Phase 3. Validate Against Real Scenarios
Run scenario checks for:
- normal assigned heartbeat
- comment-triggered wake
- blocked-task dedup behavior
- approval-resolution wake
- delegation/subtask creation
- board handoff back to user
- plan-request handling
### Phase 4. Decide Default Loading Strategy
After validation, decide whether:
- the entire main skill still loads by default, or
- only the compact core loads by default and rare sections are fetched on demand
Do not change this loading policy without validation.
## Risks
- prompt degradation on control-plane safety rules
- agents forgetting rare but important workflows
- accidental removal of repeated wording that was carrying useful behavior
- introducing ambiguous instruction precedence between the core skill and companion materials
## Preconditions Before Implementation
- define acceptance scenarios for control-plane correctness
- add at least lightweight eval or scripted scenario coverage for key Paperclip flows
- confirm how adapter/bootstrap layering should load skill content versus references
## Success Criteria
- materially lower first-run input tokens for Paperclip-coordinated agents
- no regression in checkout discipline, issue updates, blocked handling, or delegation
- no increase in malformed API usage or ownership mistakes
- agents still complete rare workflows correctly when explicitly asked

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh

View File

@@ -6,7 +6,7 @@ summary: Guide to building a custom adapter
Build a custom adapter to connect Paperclip to any agent runtime.
<Tip>
If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
</Tip>
## Package Structure

View File

@@ -32,6 +32,23 @@ export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
"../../skills",
"../../../../../skills",
];
export interface PaperclipSkillEntry {
name: string;
source: string;
}
function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/");
}
function isMaintainerOnlySkillTarget(candidate: string): boolean {
return normalizePathSlashes(candidate).includes("/.agents/skills/");
}
export function parseObject(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -95,6 +112,16 @@ export function renderTemplate(template: string, data: Record<string, unknown>)
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
}
export function joinPromptSections(
sections: Array<string | null | undefined>,
separator = "\n\n",
) {
return sections
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
.join(separator);
}
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
@@ -245,6 +272,136 @@ export async function ensureAbsoluteDirectory(
}
}
export async function resolvePaperclipSkillsDir(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<string | null> {
const candidates = [
...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
...additionalCandidates.map((candidate) => path.resolve(candidate)),
];
const seenRoots = new Set<string>();
for (const root of candidates) {
if (seenRoots.has(root)) continue;
seenRoots.add(root);
const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
if (isDirectory) return root;
}
return null;
}
export async function listPaperclipSkillEntries(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<PaperclipSkillEntry[]> {
const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates);
if (!root) return [];
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
source: path.join(root, entry.name),
}));
} catch {
return [];
}
}
export async function readPaperclipSkillMarkdown(
moduleDir: string,
skillName: string,
): Promise<string | null> {
const normalized = skillName.trim().toLowerCase();
if (!normalized) return null;
const entries = await listPaperclipSkillEntries(moduleDir);
const match = entries.find((entry) => entry.name === normalized);
if (!match) return null;
try {
return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8");
} catch {
return null;
}
}
export async function ensurePaperclipSkillSymlink(
source: string,
target: string,
linkSkill: (source: string, target: string) => Promise<void> = (linkSource, linkTarget) =>
fs.symlink(linkSource, linkTarget),
): Promise<"created" | "repaired" | "skipped"> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await linkSkill(source, target);
return "created";
}
if (!existing.isSymbolicLink()) {
return "skipped";
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return "skipped";
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) {
return "skipped";
}
const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
if (linkedPathExists) {
return "skipped";
}
await fs.unlink(target);
await linkSkill(source, target);
return "repaired";
}
export async function removeMaintainerOnlySkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
): Promise<string[]> {
const allowed = new Set(Array.from(allowedSkillNames));
try {
const entries = await fs.readdir(skillsHome, { withFileTypes: true });
const removed: string[] = [];
for (const entry of entries) {
if (allowed.has(entry.name)) continue;
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) continue;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) continue;
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
if (
!isMaintainerOnlySkillTarget(linkedPath) &&
!isMaintainerOnlySkillTarget(resolvedLinkedPath)
) {
continue;
}
await fs.unlink(target);
removed.push(entry.name);
}
return removed;
} catch {
return [];
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const resolved = await resolveCommandPath(command, cwd, env);
if (resolved) return;

View File

@@ -99,6 +99,7 @@ export interface AdapterInvocationMeta {
commandNotes?: string[];
env?: Record<string, string>;
prompt?: string;
promptMetrics?: Record<string, number>;
context?: Record<string, unknown>;
}

View File

@@ -12,6 +12,7 @@ import {
parseObject,
parseJson,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -363,7 +364,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const prompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -371,7 +373,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildClaudeArgs = (resumeSessionId: string | null) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
@@ -416,6 +435,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandNotes,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -67,6 +67,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;

View File

@@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string {
const fromEnv = nonEmpty(env.CODEX_HOME);
if (fromEnv) return path.resolve(fromEnv);
return path.join(os.homedir(), ".codex");
}
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
}
function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null {
if (!isWorktreeMode(env)) return null;
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);
if (!paperclipHome) return null;
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);
if (instanceId) {
return path.resolve(paperclipHome, "instances", instanceId, "codex-home");
}
return path.resolve(paperclipHome, "codex-home");
}
async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
return;
}
if (!existing.isSymbolicLink()) {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
await fs.unlink(target);
await fs.symlink(source, target);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (existing) return;
await ensureParentDir(target);
await fs.copyFile(source, target);
}
export async function prepareWorktreeCodexHome(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
): Promise<string | null> {
const targetHome = resolveWorktreeCodexHomeDir(env);
if (!targetHome) return null;
const sourceHome = resolveCodexHomeDir(env);
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
await fs.mkdir(targetHome, { recursive: true });
for (const name of SYMLINKED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureSymlink(path.join(targetHome, name), source);
}
for (const name of COPIED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureCopiedFile(path.join(targetHome, name), source);
}
await onLog(
"stderr",
`[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
);
return targetHome;
}

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
@@ -13,17 +12,18 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
@@ -61,39 +61,95 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
pathExists(path.join(candidate, "package.json")),
pathExists(path.join(candidate, "server")),
pathExists(path.join(candidate, "packages", "adapter-utils")),
]);
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
if (path.basename(candidate) !== skillName) return false;
const skillsRoot = path.dirname(candidate);
if (path.basename(skillsRoot) !== "skills") return false;
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
let cursor = path.dirname(skillsRoot);
for (let depth = 0; depth < 6; depth += 1) {
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
return null;
return false;
}
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
linkSkill?: (source: string, target: string) => Promise<void>;
};
const skillsHome = path.join(codexHomeDir(), "skills");
export async function ensureCodexSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCodexSkillsInjectedOptions = {},
) {
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
);
}
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
const existing = await fs.lstat(target).catch(() => null);
if (existing?.isSymbolicLink()) {
const linkedPath = await fs.readlink(target).catch(() => null);
const resolvedLinkedPath = linkedPath
? path.resolve(path.dirname(target), linkedPath)
: null;
if (
resolvedLinkedPath &&
resolvedLinkedPath !== entry.source &&
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
) {
await fs.unlink(target);
if (linkSkill) {
await linkSkill(entry.source, target);
} else {
await fs.symlink(entry.source, target);
}
await onLog(
"stderr",
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
);
continue;
}
}
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
@@ -152,12 +208,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
await ensureCodexSkillsInjected(
onLog,
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
if (effectiveCodexHome) {
env.CODEX_HOME = effectiveCodexHome;
}
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@@ -270,6 +339,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -277,6 +347,7 @@ 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`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -301,7 +372,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -309,8 +381,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
@@ -338,6 +428,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -1,4 +1,4 @@
export { execute } from "./execute.js";
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";

View File

@@ -71,6 +71,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -13,8 +12,12 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
@@ -23,10 +26,6 @@ import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
@@ -82,16 +81,9 @@ function cursorSkillsHome(): string {
return path.join(os.homedir(), ".cursor", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
type EnsureCursorSkillsInjectedOptions = {
skillsDir?: string | null;
skillsEntries?: Array<{ name: string; source: string }>;
skillsHome?: string;
linkSkill?: (source: string, target: string) => Promise<void>;
};
@@ -100,8 +92,13 @@ export async function ensureCursorSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCursorSkillsInjectedOptions = {},
) {
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = options.skillsEntries
?? (options.skillsDir
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) }))
: await listPaperclipSkillEntries(__moduleDir));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? cursorSkillsHome();
try {
@@ -113,31 +110,26 @@ export async function ensureCursorSkillsInjected(
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`,
);
return;
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await linkSkill(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
@@ -277,6 +269,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -284,6 +277,7 @@ 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`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -316,7 +310,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -324,9 +319,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
@@ -349,6 +364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -62,6 +62,7 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;

View File

@@ -12,7 +12,11 @@ import {
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
@@ -29,10 +33,6 @@ import {
import { firstNonEmptyLine } from "./utils.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
@@ -73,14 +73,6 @@ function renderApiAccessNote(env: Record<string, string>): string {
].join("\n");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
@@ -93,8 +85,8 @@ function geminiSkillsHome(): string {
async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = geminiSkillsHome();
try {
@@ -106,28 +98,27 @@ async function ensureGeminiSkillsInjected(
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`,
);
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`,
);
} catch (err) {
await onLog(
"stderr",
@@ -278,7 +269,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -286,10 +278,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
@@ -319,6 +332,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -56,6 +56,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;

View File

@@ -9,6 +9,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -233,7 +234,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -241,8 +243,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
@@ -264,6 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -7,6 +7,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
function resolveOpenCodeCommand(input: unknown): string {
const envOverride =
@@ -115,14 +116,14 @@ export async function discoverOpenCodeModels(input: {
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`opencode models` timed out.");
throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`);
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);

View File

@@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)

View File

@@ -9,10 +9,14 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -20,10 +24,6 @@ import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
@@ -50,34 +50,32 @@ function parseModelId(model: string | null): string | null {
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
await fs.mkdir(piSkillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const removedSkills = await removeMaintainerOnlySkillSymlinks(
piSkillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`,
);
}
for (const entry of skillsEntries) {
const target = path.join(piSkillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`,
);
} catch (err) {
await onLog(
@@ -273,7 +271,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
systemPromptExtension = promptTemplate;
}
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -281,18 +280,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
// User prompt is simple - just the rendered prompt template without instructions
const userPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
const renderedHeartbeatPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const userPrompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedHeartbeatPrompt,
]);
const promptMetrics = {
systemPromptChars: renderedSystemPromptExtension.length,
promptChars: userPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedHeartbeatPrompt.length,
};
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
@@ -348,6 +355,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt: userPrompt,
promptMetrics,
context,
});
}

View File

@@ -48,6 +48,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;

View File

@@ -730,7 +730,7 @@ export async function ensurePostgresDatabase(
`;
if (existing.length > 0) return "exists";
await sql.unsafe(`create database "${databaseName}"`);
await sql.unsafe(`create database "${databaseName}" encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`);
return "created";
} finally {
await sql.end();

View File

@@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: {
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
@@ -96,6 +97,7 @@ async function ensureEmbeddedPostgresConnection(
password: "paperclip",
port: preferredPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});

View File

@@ -78,6 +78,10 @@ export const wakeAgentSchema = z.object({
reason: z.string().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(),
idempotencyKey: z.string().optional().nullable(),
forceFreshSession: z.preprocess(
(value) => (value === null ? undefined : value),
z.boolean().optional().default(false),
),
});
export type WakeAgent = z.infer<typeof wakeAgentSchema>;

View File

@@ -1,54 +0,0 @@
# v0.3.1
> Released: 2026-03-12
## Highlights
- **Gemini CLI adapter** — Full local adapter support for Google's Gemini CLI. Includes API-key detection, turn-limit handling, sandbox and approval modes, skill injection into `~/.gemini/`, and yolo-mode default. ([#452](https://github.com/paperclipai/paperclip/pull/452), [#656](https://github.com/paperclipai/paperclip/pull/656), @aaaaron)
- **Run transcript polish** — Run transcripts render markdown, fold command stdout, redact home paths and user identities, and display humanized event labels across both detail and live surfaces. ([#648](https://github.com/paperclipai/paperclip/pull/648), [#695](https://github.com/paperclipai/paperclip/pull/695))
- **Inbox refinements** — Improved tab behavior, badge counts aligned with visible unread items, better mobile layout, and smoother new-issue submit state. ([#613](https://github.com/paperclipai/paperclip/pull/613))
- **Improved onboarding wizard** — Onboarding now shows Claude Code and Codex as recommended adapters, collapses other types, and features animated step transitions with clickable tabs. Adapter environment checks animate on success and show debug output only on failure. ([#700](https://github.com/paperclipai/paperclip/pull/700))
## Improvements
- **Instance heartbeat settings sidebar** — View and manage heartbeat configuration directly from the instance settings page with compact grouped run lists. ([#697](https://github.com/paperclipai/paperclip/pull/697))
- **Project and agent configuration tabs** — New tabbed configuration UI for projects and agents, including execution workspace policy settings. ([#613](https://github.com/paperclipai/paperclip/pull/613))
- **Agent runs tab** — Agent detail pages now include a dedicated runs tab.
- **Configurable attachment content types** — The `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` env var lets operators control which file types can be uploaded. ([#495](https://github.com/paperclipai/paperclip/pull/495), @subhendukundu)
- **Default max turns raised to 300** — Agents now default to 300 max turns instead of the previous limit. ([#701](https://github.com/paperclipai/paperclip/pull/701))
- **Issue creator shown in sidebar** — The issue properties pane now displays who created each issue. ([#145](https://github.com/paperclipai/paperclip/pull/145), @cschneid)
- **Company-aware 404 handling** — The UI now shows company-scoped not-found pages instead of a generic error.
- **Tools for Worktree workflow for developers** — New `paperclipai worktree:make` command provisions isolated development instances with their own database, secrets, favicon branding, and git hooks. Worktrees support minimal seed mode, start-point selection, and automatic workspace rebinding. ([#496](https://github.com/paperclipai/paperclip/pull/496), [#530](https://github.com/paperclipai/paperclip/pull/530), [#545](https://github.com/paperclipai/paperclip/pull/545))
## Fixes
- **Gemini Docker build** — Include the Gemini adapter manifest in the Docker deps stage so production builds succeed. ([#706](https://github.com/paperclipai/paperclip/pull/706), @zvictor)
- **Approval retries made idempotent** — Duplicate approval submissions no longer create duplicate records. ([#502](https://github.com/paperclipai/paperclip/pull/502), @davidahmann)
- **Heartbeat cost recording** — Costs are now routed through `costService` instead of being recorded inline, fixing missing cost attribution. ([#386](https://github.com/paperclipai/paperclip/pull/386), @domocarroll)
- **Claude Code env var leak** — Child adapter processes no longer inherit Claude Code's internal environment variables. ([#485](https://github.com/paperclipai/paperclip/pull/485), @jknair)
- **`parentId` query filter** — The issues list endpoint now correctly applies the `parentId` filter. ([#491](https://github.com/paperclipai/paperclip/pull/491), @lazmo88)
- **Remove `Cmd+1..9` shortcut** — The company-switch keyboard shortcut conflicted with browser tab switching and has been removed. ([#628](https://github.com/paperclipai/paperclip/pull/628), @STRML)
- **IME composition Enter** — Pressing Enter during IME composition in the new-issue title no longer moves focus prematurely. ([#578](https://github.com/paperclipai/paperclip/pull/578), @kaonash)
- **Restart hint after hostname change** — The CLI now reminds users to restart the server after changing allowed hostnames. ([#549](https://github.com/paperclipai/paperclip/pull/549), @mvanhorn)
- **Default `dangerouslySkipPermissions` for unattended agents** — Agents running without a terminal now default to skipping permission prompts instead of hanging. ([#388](https://github.com/paperclipai/paperclip/pull/388), @ohld)
- **Remove stale `paperclip` property from OpenClaw Gateway** — Cleaned up an invalid agent parameter that caused warnings. ([#626](https://github.com/paperclipai/paperclip/pull/626), @openagen)
- **Issue description overflow** — Long descriptions no longer break the layout.
- **Worktree JWT persistence** — Environment-sensitive JWT config is now correctly carried into worktree instances.
- **Dev migration prompt** — Fixed embedded `db:migrate` flow for local development.
- **Markdown link dialog positioning** — The link insertion dialog no longer renders off-screen.
- **Pretty logger metadata** — Server log metadata stays on one line instead of wrapping.
## Upgrade Guide
Two new database migrations (`0026`, `0027`) will run automatically on startup:
- **Migration 0026** adds the `workspace_runtime_services` table for worktree-aware runtime support.
- **Migration 0027** adds `execution_workspace_settings` to issues and `execution_workspace_policy` to projects.
Both are additive (new table and new columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
## Contributors
Thank you to everyone who contributed to this release!
@aaaaron, @adamrobbie-nudge, @cschneid, @davidahmann, @domocarroll, @jknair, @kaonash, @lazmo88, @mvanhorn, @ohld, @openagen, @STRML, @subhendukundu, @zvictor

View File

@@ -0,0 +1,48 @@
# Token Optimization Implementation Report
Implemented the token-optimization plan across heartbeat orchestration, issue context APIs, adapter prompt construction, skill exposure, and agent configuration UX.
The main behavior changes are:
- Heartbeat telemetry now normalizes sessioned local adapter usage as per-run deltas instead of blindly trusting cumulative session totals.
- Timer and manual wakes now preserve task sessions by default; fresh sessions are forced only for explicit `forceFreshSession` wakes or new issue assignment wakes.
- Heartbeat session rotation is now policy-driven in the control plane, with a handoff note injected when a session is compacted and restarted.
- Paperclip issue context now has incremental APIs: `GET /api/agents/me/inbox-lite`, `GET /api/issues/:id/heartbeat-context`, and comment delta queries via `GET /api/issues/:id/comments?after=...&order=asc`.
- The `paperclip` skill now teaches agents to use those compact/incremental APIs first, while keeping full-thread fetches as a cold-start fallback.
- All local adapters now separate first-session bootstrap prompts from per-heartbeat prompt templates, and emit prompt size metrics in invocation metadata.
- Adapter create flows now persist `bootstrapPromptTemplate` correctly.
- The agent config UI now explains the difference between bootstrap prompts and heartbeat prompts and warns about prompt churn.
- Runtime skill defaults now include `paperclip`, `para-memory-files`, and `paperclip-create-agent`. `create-agent-adapter` was moved to `.agents/skills/create-agent-adapter`.
Important follow-up finding from real-run review:
- `codex_local` currently injects Paperclip skills into the shared Codex skills home (`$CODEX_HOME/skills` or `~/.codex/skills`) rather than mounting a worktree-local skill directory.
- If a Paperclip-owned skill symlink already points at another live checkout, the adapter currently skips it instead of repointing it.
- In practice, this means a worktree can contain newer `skills/paperclip/SKILL.md` guidance while Codex still follows an older checkout's skill content.
- That likely explains why PAP-507 still showed full issue/comment reload behavior even though the incremental context work was already implemented in this branch.
- This should be treated as a separate follow-up item for `codex_local` skill isolation or symlink repair.
Files with the most important implementation work:
- `server/src/services/heartbeat.ts`
- `server/src/services/issues.ts`
- `server/src/routes/issues.ts`
- `server/src/routes/agents.ts`
- `server/src/routes/access.ts`
- `skills/paperclip/SKILL.md`
- `packages/adapters/*/src/server/execute.ts`
- `packages/adapters/*/src/ui/build-config.ts`
- `ui/src/components/AgentConfigForm.tsx`
Verification completed successfully:
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`
While verifying, I also fixed two existing embedded-postgres typing mismatches so repo-wide `typecheck` and `build` pass again:
- `packages/db/src/migration-runtime.ts`
- `cli/src/commands/worktree.ts`
Next useful follow-up is measuring the before/after effect in real runs now that telemetry is less misleading and prompt/session reuse behavior is consistent across adapters.

View File

@@ -196,36 +196,6 @@ npm_version_exists() {
[ "$resolved" = "$version" ]
}
npm_package_version_exists() {
local package_name="$1"
local version="$2"
local resolved
resolved="$(npm view "${package_name}@${version}" version 2>/dev/null || true)"
[ "$resolved" = "$version" ]
}
wait_for_npm_package_version() {
local package_name="$1"
local version="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-5}"
local attempt=1
while [ "$attempt" -le "$attempts" ]; do
if npm_package_version_exists "$package_name" "$version"; then
return 0
fi
if [ "$attempt" -lt "$attempts" ]; then
sleep "$delay_seconds"
fi
attempt=$((attempt + 1))
done
return 1
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."

View File

@@ -181,12 +181,10 @@ for (const rel of roots) {
rows.sort((a, b) => a[0].localeCompare(b[0]));
for (const [dir, name] of rows) {
const pkgPath = path.join(root, dir, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const key = `${dir}\t${name}\t${pkg.version}`;
const key = `${dir}\t${name}`;
if (seen.has(key)) continue;
seen.add(key);
process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`);
process.stdout.write(`${dir}\t${name}\n`);
}
NODE
}
@@ -358,8 +356,6 @@ if [ "$canary" = true ]; then
fi
fi
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
@@ -407,31 +403,6 @@ else
npx changeset publish
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
release_info ""
release_info "==> Post-publish verification: Confirming npm package availability..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES=""
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
[ -z "$pkg_name" ] && continue
release_info " Checking $pkg_name@$pkg_version"
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
release_info " ✓ Found on npm"
continue
fi
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, "
fi
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
done <<< "$VERSIONED_PACKAGE_INFO"
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
fi
release_info " ✓ Verified all versioned packages are available on npm"
fi
release_info ""

View File

@@ -38,9 +38,9 @@
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/shared": "workspace:*",

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execute } from "@paperclipai/adapter-codex-local/server";
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const payload = {
argv: process.argv.slice(2),
prompt: fs.readFileSync(0, "utf8"),
codexHome: process.env.CODEX_HOME || null,
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort(),
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
}
console.log(JSON.stringify({ type: "thread.started", thread_id: "codex-session-1" }));
console.log(JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }));
console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
prompt: string;
codexHome: string | null;
paperclipEnvKeys: string[];
};
describe("codex execute", () => {
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const isolatedCodexHome = path.join(paperclipHome, "instances", "worktree-1", "codex-home");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
const previousCodexHome = process.env.CODEX_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.CODEX_HOME = sharedCodexHome;
try {
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(isolatedCodexHome);
expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"]));
expect(capture.prompt).toContain("Follow the paperclip heartbeat.");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_API_KEY",
"PAPERCLIP_API_URL",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_RUN_ID",
]),
);
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip");
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home");
const explicitCodexHome = path.join(root, "explicit-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
const previousCodexHome = process.env.CODEX_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.CODEX_HOME = sharedCodexHome;
try {
const result = await execute({
runId: "run-2",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
CODEX_HOME: explicitCodexHome,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(explicitCodexHome);
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function createPaperclipRepoSkill(root: string, skillName: string) {
await fs.mkdir(path.join(root, "server"), { recursive: true });
await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true });
await fs.mkdir(path.join(root, "skills", skillName), { recursive: true });
await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8");
await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8");
await fs.writeFile(
path.join(root, "skills", skillName, "SKILL.md"),
`---\nname: ${skillName}\n---\n`,
"utf8",
);
}
async function createCustomSkill(root: string, skillName: string) {
await fs.mkdir(path.join(root, "custom", skillName), { recursive: true });
await fs.writeFile(
path.join(root, "custom", skillName, "SKILL.md"),
`---\nname: ${skillName}\n---\n`,
"utf8",
);
}
describe("codex local adapter skill injection", () => {
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("repairs a Codex Paperclip skill symlink that still points at another live checkout", async () => {
const currentRepo = await makeTempDir("paperclip-codex-current-");
const oldRepo = await makeTempDir("paperclip-codex-old-");
const skillsHome = await makeTempDir("paperclip-codex-home-");
cleanupDirs.add(currentRepo);
cleanupDirs.add(oldRepo);
cleanupDirs.add(skillsHome);
await createPaperclipRepoSkill(currentRepo, "paperclip");
await createPaperclipRepoSkill(oldRepo, "paperclip");
await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));
const logs: string[] = [];
await ensureCodexSkillsInjected(
async (_stream, chunk) => {
logs.push(chunk);
},
{
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
},
);
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
);
expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);
});
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
const currentRepo = await makeTempDir("paperclip-codex-current-");
const customRoot = await makeTempDir("paperclip-codex-custom-");
const skillsHome = await makeTempDir("paperclip-codex-home-");
cleanupDirs.add(currentRepo);
cleanupDirs.add(customRoot);
cleanupDirs.add(skillsHome);
await createPaperclipRepoSkill(currentRepo, "paperclip");
await createCustomSkill(customRoot, "paperclip");
await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip"));
await ensureCodexSkillsInjected(async () => {}, {
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
});
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
);
});
});

View File

@@ -93,16 +93,26 @@ describe("shouldResetTaskSessionForWake", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
});
it("resets session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(true);
it("preserves session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
});
it("resets session context on manual on-demand invokes", () => {
it("preserves session context on manual on-demand invokes by default", () => {
expect(
shouldResetTaskSessionForWake({
wakeSource: "on_demand",
wakeTriggerDetail: "manual",
}),
).toBe(false);
});
it("resets session context when a fresh session is explicitly requested", () => {
expect(
shouldResetTaskSessionForWake({
wakeSource: "on_demand",
wakeTriggerDetail: "manual",
forceFreshSession: true,
}),
).toBe(true);
});

View File

@@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
} from "@paperclipai/adapter-utils/server-utils";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("paperclip skill utils", () => {
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("lists runtime skills from ./skills without pulling in .agents/skills", async () => {
const root = await makeTempDir("paperclip-skill-roots-");
cleanupDirs.add(root);
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
await fs.mkdir(moduleDir, { recursive: true });
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
const entries = await listPaperclipSkillEntries(moduleDir);
expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]);
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
});
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
const root = await makeTempDir("paperclip-skill-cleanup-");
cleanupDirs.add(root);
const skillsHome = path.join(root, "skills-home");
const runtimeSkill = path.join(root, "skills", "paperclip");
const customSkill = path.join(root, "custom", "release-notes");
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
await fs.mkdir(skillsHome, { recursive: true });
await fs.mkdir(runtimeSkill, { recursive: true });
await fs.mkdir(customSkill, { recursive: true });
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
expect(removed).toEqual(["release"]);
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest";
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
import {
applyUiBranding,
getWorktreeUiBranding,
isWorktreeUiBrandingEnabled,
renderFaviconLinks,
renderRuntimeBrandingMeta,
} from "../ui-branding.js";
const TEMPLATE = `<!doctype html>
<head>
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
@@ -18,21 +26,57 @@ describe("ui branding", () => {
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
});
it("renders the worktree favicon asset set when enabled", () => {
const links = renderFaviconLinks(true);
expect(links).toContain("/worktree-favicon.ico");
expect(links).toContain("/worktree-favicon.svg");
expect(links).toContain("/worktree-favicon-32x32.png");
expect(links).toContain("/worktree-favicon-16x16.png");
it("resolves name, color, and text color for worktree branding", () => {
const branding = getWorktreeUiBranding({
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
});
expect(branding.enabled).toBe(true);
expect(branding.name).toBe("paperclip-pr-432");
expect(branding.color).toBe("#4f86f7");
expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/);
expect(branding.faviconHref).toContain("data:image/svg+xml,");
});
it("rewrites the favicon block for worktree instances only", () => {
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
expect(branded).toContain("/worktree-favicon.svg");
it("renders a dynamic worktree favicon when enabled", () => {
const links = renderFaviconLinks(
getWorktreeUiBranding({
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
}),
);
expect(links).toContain("data:image/svg+xml,");
expect(links).toContain('rel="shortcut icon"');
});
it("renders runtime branding metadata for the ui", () => {
const meta = renderRuntimeBrandingMeta(
getWorktreeUiBranding({
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
}),
);
expect(meta).toContain('name="paperclip-worktree-name"');
expect(meta).toContain('content="paperclip-pr-432"');
expect(meta).toContain('name="paperclip-worktree-color"');
});
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
const branded = applyUiBranding(TEMPLATE, {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
});
expect(branded).toContain("data:image/svg+xml,");
expect(branded).toContain('name="paperclip-worktree-name"');
expect(branded).not.toContain('href="/favicon.svg"');
const defaultHtml = applyUiBranding(TEMPLATE, {});
expect(defaultHtml).toContain('href="/favicon.svg"');
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
expect(defaultHtml).not.toContain('name="paperclip-worktree-name"');
});
});

View File

@@ -53,6 +53,7 @@ type EmbeddedPostgresCtor = new (opts: {
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
@@ -334,6 +335,7 @@ export async function startServer(): Promise<StartedServer> {
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: appendEmbeddedPostgresLog,
onError: appendEmbeddedPostgresLog,
});
@@ -512,11 +514,14 @@ export async function startServer(): Promise<StartedServer> {
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any);
// Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
void heartbeat.reapOrphanedRuns().catch((err) => {
logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
});
// Reap orphaned running runs at startup while in-memory execution state is empty,
// then resume any persisted queued runs that were waiting on the previous process.
void heartbeat
.reapOrphanedRuns()
.then(() => heartbeat.resumeQueuedRuns())
.catch((err) => {
logger.error({ err }, "startup heartbeat recovery failed");
});
setInterval(() => {
void heartbeat
.tickTimers(new Date())
@@ -529,11 +534,13 @@ export async function startServer(): Promise<StartedServer> {
logger.error({ err }, "heartbeat timer tick failed");
});
// Periodically reap orphaned runs (5-min staleness threshold)
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward.
void heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
.then(() => heartbeat.resumeQueuedRuns())
.catch((err) => {
logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
logger.error({ err }, "periodic heartbeat recovery failed");
});
}, config.heartbeatSchedulerIntervalMs);
}

View File

@@ -97,7 +97,11 @@ function requestBaseUrl(req: Request) {
function readSkillMarkdown(skillName: string): string | null {
const normalized = skillName.trim().toLowerCase();
if (normalized !== "paperclip" && normalized !== "paperclip-create-agent")
if (
normalized !== "paperclip" &&
normalized !== "paperclip-create-agent" &&
normalized !== "para-memory-files"
)
return null;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
@@ -1610,6 +1614,10 @@ export function accessRoutes(
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
{
name: "para-memory-files",
path: "/api/skills/para-memory-files"
},
{
name: "paperclip-create-agent",
path: "/api/skills/paperclip-create-agent"

View File

@@ -575,6 +575,34 @@ export function agentRoutes(db: Db) {
res.json({ ...agent, chainOfCommand });
});
router.get("/agents/me/inbox-lite", async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
res.status(401).json({ error: "Agent authentication required" });
return;
}
const issuesSvc = issueService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked",
});
res.json(
rows.map((issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: issue.goalId,
parentId: issue.parentId,
updatedAt: issue.updatedAt,
activeRun: issue.activeRun,
})),
);
});
router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
@@ -1275,6 +1303,7 @@ export function agentRoutes(db: Db) {
contextSnapshot: {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
forceFreshSession: req.body.forceFreshSession === true,
},
});

View File

@@ -28,6 +28,8 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = issueService(db);
@@ -314,6 +316,79 @@ export function issueRoutes(db: Db, storage: StorageService) {
});
});
router.get("/issues/:id/heartbeat-context", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const wakeCommentId =
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
? req.query.wakeCommentId.trim()
: null;
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,
]);
res.json({
issue: {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
updatedAt: issue.updatedAt,
},
ancestors: ancestors.map((ancestor) => ({
id: ancestor.id,
identifier: ancestor.identifier,
title: ancestor.title,
status: ancestor.status,
priority: ancestor.priority,
})),
project: project
? {
id: project.id,
name: project.name,
status: project.status,
targetDate: project.targetDate,
}
: null,
goal: goal
? {
id: goal.id,
title: goal.title,
status: goal.status,
level: goal.level,
parentId: goal.parentId,
}
: null,
commentCursor,
wakeComment:
wakeComment && wakeComment.issueId === issue.id
? wakeComment
: null,
});
});
router.post("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -791,7 +866,29 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const comments = await svc.listComments(id);
const afterCommentId =
typeof req.query.after === "string" && req.query.after.trim().length > 0
? req.query.after.trim()
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
? req.query.afterCommentId.trim()
: null;
const order =
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
? "asc"
: "desc";
const limitRaw =
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
? Number(req.query.limit)
: null;
const limit =
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
: null;
const comments = await svc.listComments(id, {
afterCommentId,
order,
limit,
});
res.json(comments);
});

View File

@@ -18,7 +18,7 @@ import { logger } from "../middleware/logger.js";
import { publishLiveEvent } from "./live-events.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
@@ -47,6 +47,14 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
@@ -117,6 +125,26 @@ interface WakeupOptions {
contextSnapshot?: Record<string, unknown>;
}
type UsageTotals = {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
};
type SessionCompactionPolicy = {
enabled: boolean;
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
};
type SessionCompactionDecision = {
rotate: boolean;
reason: string | null;
handoffMarkdown: string | null;
previousRunId: string | null;
};
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | null;
useProjectWorkspace: boolean | null;
@@ -142,6 +170,88 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null;
return {
inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))),
cachedInputTokens: Math.max(0, Math.floor(asNumber(usage.cachedInputTokens, 0))),
outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))),
};
}
function readRawUsageTotals(usageJson: unknown): UsageTotals | null {
const parsed = parseObject(usageJson);
if (Object.keys(parsed).length === 0) return null;
const inputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0))),
);
const cachedInputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawCachedInputTokens, asNumber(parsed.cachedInputTokens, 0))),
);
const outputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0))),
);
if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) {
return null;
}
return {
inputTokens,
cachedInputTokens,
outputTokens,
};
}
function deriveNormalizedUsageDelta(current: UsageTotals | null, previous: UsageTotals | null): UsageTotals | null {
if (!current) return null;
if (!previous) return { ...current };
const inputTokens = current.inputTokens >= previous.inputTokens
? current.inputTokens - previous.inputTokens
: current.inputTokens;
const cachedInputTokens = current.cachedInputTokens >= previous.cachedInputTokens
? current.cachedInputTokens - previous.cachedInputTokens
: current.cachedInputTokens;
const outputTokens = current.outputTokens >= previous.outputTokens
? current.outputTokens - previous.outputTokens
: current.outputTokens;
return {
inputTokens: Math.max(0, inputTokens),
cachedInputTokens: Math.max(0, cachedInputTokens),
outputTokens: Math.max(0, outputTokens),
};
}
function formatCount(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return "0";
return value.toLocaleString("en-US");
}
function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy {
const runtimeConfig = parseObject(agent.runtimeConfig);
const heartbeat = parseObject(runtimeConfig.heartbeat);
const compaction = parseObject(
heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction,
);
const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType);
const enabled = compaction.enabled === undefined
? supportsSessions
: asBoolean(compaction.enabled, supportsSessions);
return {
enabled,
maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))),
maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))),
maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))),
};
}
export function resolveRuntimeSessionParamsForWorkspace(input: {
agentId: string;
previousSessionParams: Record<string, unknown> | null;
@@ -246,29 +356,20 @@ function deriveTaskKey(
export function shouldResetTaskSessionForWake(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return true;
const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource);
if (wakeSource === "timer") return true;
const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail);
return wakeSource === "on_demand" && wakeTriggerDetail === "manual";
return false;
}
function describeSessionResetReason(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
if (contextSnapshot?.forceFreshSession === true) return "forceFreshSession was requested";
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource);
if (wakeSource === "timer") return "wake source is timer";
const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail);
if (wakeSource === "on_demand" && wakeTriggerDetail === "manual") {
return "this is a manual invoke";
}
return null;
}
@@ -455,6 +556,7 @@ export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
const activeRunExecutions = new Set<string>();
async function getAgent(agentId: string) {
return db
@@ -500,6 +602,176 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getLatestRunForSession(
agentId: string,
sessionId: string,
opts?: { excludeRunId?: string | null },
) {
const conditions = [
eq(heartbeatRuns.agentId, agentId),
eq(heartbeatRuns.sessionIdAfter, sessionId),
];
if (opts?.excludeRunId) {
conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`);
}
return db
.select()
.from(heartbeatRuns)
.where(and(...conditions))
.orderBy(desc(heartbeatRuns.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function getOldestRunForSession(agentId: string, sessionId: string) {
return db
.select({
id: heartbeatRuns.id,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.sessionIdAfter, sessionId)))
.orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function resolveNormalizedUsageForSession(input: {
agentId: string;
runId: string;
sessionId: string | null;
rawUsage: UsageTotals | null;
}) {
const { agentId, runId, sessionId, rawUsage } = input;
if (!sessionId || !rawUsage) {
return {
normalizedUsage: rawUsage,
previousRawUsage: null as UsageTotals | null,
derivedFromSessionTotals: false,
};
}
const previousRun = await getLatestRunForSession(agentId, sessionId, { excludeRunId: runId });
const previousRawUsage = readRawUsageTotals(previousRun?.usageJson);
return {
normalizedUsage: deriveNormalizedUsageDelta(rawUsage, previousRawUsage),
previousRawUsage,
derivedFromSessionTotals: previousRawUsage !== null,
};
}
async function evaluateSessionCompaction(input: {
agent: typeof agents.$inferSelect;
sessionId: string | null;
issueId: string | null;
}): Promise<SessionCompactionDecision> {
const { agent, sessionId, issueId } = input;
if (!sessionId) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const policy = parseSessionCompactionPolicy(agent);
if (!policy.enabled) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const fetchLimit = Math.max(policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0, 4);
const runs = await db
.select({
id: heartbeatRuns.id,
createdAt: heartbeatRuns.createdAt,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
error: heartbeatRuns.error,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId)))
.orderBy(desc(heartbeatRuns.createdAt))
.limit(fetchLimit);
if (runs.length === 0) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const latestRun = runs[0] ?? null;
const oldestRun =
policy.maxSessionAgeHours > 0
? await getOldestRunForSession(agent.id, sessionId)
: runs[runs.length - 1] ?? latestRun;
const latestRawUsage = readRawUsageTotals(latestRun?.usageJson);
const sessionAgeHours =
latestRun && oldestRun
? Math.max(
0,
(new Date(latestRun.createdAt).getTime() - new Date(oldestRun.createdAt).getTime()) / (1000 * 60 * 60),
)
: 0;
let reason: string | null = null;
if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) {
reason = `session exceeded ${policy.maxSessionRuns} runs`;
} else if (
policy.maxRawInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.inputTokens >= policy.maxRawInputTokens
) {
reason =
`session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxRawInputTokens)})`;
} else if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) {
reason = `session age reached ${Math.floor(sessionAgeHours)} hours`;
}
if (!reason || !latestRun) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: latestRun?.id ?? null,
};
}
const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson);
const latestTextSummary =
readNonEmptyString(latestSummary?.summary) ??
readNonEmptyString(latestSummary?.result) ??
readNonEmptyString(latestSummary?.message) ??
readNonEmptyString(latestRun.error);
const handoffMarkdown = [
"Paperclip session handoff:",
`- Previous session: ${sessionId}`,
issueId ? `- Issue: ${issueId}` : "",
`- Rotation reason: ${reason}`,
latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "",
"Continue from the current task state. Rebuild only the minimum context you need.",
]
.filter(Boolean)
.join("\n");
return {
rotate: true,
reason,
handoffMarkdown,
previousRunId: latestRun.id,
};
}
async function resolveSessionBeforeForWakeup(
agent: typeof agents.$inferSelect,
taskKey: string | null,
@@ -959,7 +1231,7 @@ export function heartbeatService(db: Db) {
const reaped: string[] = [];
for (const run of activeRuns) {
if (runningProcesses.has(run.id)) continue;
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
// Apply staleness threshold to avoid false positives
if (staleThresholdMs > 0) {
@@ -998,14 +1270,27 @@ export function heartbeatService(db: Db) {
return { reaped: reaped.length, runIds: reaped };
}
async function resumeQueuedRuns() {
const queuedRuns = await db
.select({ agentId: heartbeatRuns.agentId })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.status, "queued"));
const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))];
for (const agentId of agentIds) {
await startNextQueuedRunForAgent(agentId);
}
}
async function updateRuntimeState(
agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect,
result: AdapterExecutionResult,
session: { legacySessionId: string | null },
normalizedUsage?: UsageTotals | null,
) {
await ensureRuntimeState(agent);
const usage = result.usage;
const usage = normalizedUsage ?? normalizeUsageTotals(result.usage);
const inputTokens = usage?.inputTokens ?? 0;
const outputTokens = usage?.outputTokens ?? 0;
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
@@ -1089,6 +1374,9 @@ export function heartbeatService(db: Db) {
run = claimed;
}
activeRunExecutions.add(run.id);
try {
const agent = await getAgent(run.agentId);
if (!agent) {
await setRunStatus(runId, "failed", {
@@ -1254,15 +1542,42 @@ export function heartbeatService(db: Db) {
context.projectId = executionWorkspace.projectId;
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId(
let previousSessionDisplayId = truncateDisplayId(
taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback,
);
let runtimeSessionIdForAdapter =
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
let runtimeSessionParamsForAdapter = runtimeSessionParams;
const sessionCompaction = await evaluateSessionCompaction({
agent,
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
issueId,
});
if (sessionCompaction.rotate) {
context.paperclipSessionHandoffMarkdown = sessionCompaction.handoffMarkdown;
context.paperclipSessionRotationReason = sessionCompaction.reason;
context.paperclipPreviousSessionId = previousSessionDisplayId ?? runtimeSessionIdForAdapter;
runtimeSessionIdForAdapter = null;
runtimeSessionParamsForAdapter = null;
previousSessionDisplayId = null;
if (sessionCompaction.reason) {
runtimeWorkspaceWarnings.push(
`Starting a fresh session because ${sessionCompaction.reason}.`,
);
}
} else {
delete context.paperclipSessionHandoffMarkdown;
delete context.paperclipSessionRotationReason;
delete context.paperclipPreviousSessionId;
}
const runtimeForAdapter = {
sessionId: readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback,
sessionParams: runtimeSessionParams,
sessionId: runtimeSessionIdForAdapter,
sessionParams: runtimeSessionParamsForAdapter,
sessionDisplayId: previousSessionDisplayId,
taskKey,
};
@@ -1506,6 +1821,14 @@ export function heartbeatService(db: Db) {
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
});
const rawUsage = normalizeUsageTotals(adapterResult.usage);
const sessionUsageResolution = await resolveNormalizedUsageForSession({
agentId: agent.id,
runId: run.id,
sessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId,
rawUsage,
});
const normalizedUsage = sessionUsageResolution.normalizedUsage;
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
const latestRun = await getRun(run.id);
@@ -1534,9 +1857,23 @@ export function heartbeatService(db: Db) {
: "failed";
const usageJson =
adapterResult.usage || adapterResult.costUsd != null
normalizedUsage || adapterResult.costUsd != null
? ({
...(adapterResult.usage ?? {}),
...(normalizedUsage ?? {}),
...(rawUsage ? {
rawInputTokens: rawUsage.inputTokens,
rawCachedInputTokens: rawUsage.cachedInputTokens,
rawOutputTokens: rawUsage.outputTokens,
} : {}),
...(sessionUsageResolution.derivedFromSessionTotals ? { usageSource: "session_delta" } : {}),
...((nextSessionState.displayId ?? nextSessionState.legacySessionId)
? { persistedSessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId }
: {}),
sessionReused: runtimeForAdapter.sessionId != null || runtimeForAdapter.sessionDisplayId != null,
taskSessionReused: taskSessionForRun != null,
freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}),
...(adapterResult.billingType ? { billingType: adapterResult.billingType } : {}),
} as Record<string, unknown>)
@@ -1593,7 +1930,7 @@ export function heartbeatService(db: Db) {
if (finalizedRun) {
await updateRuntimeState(agent, finalizedRun, adapterResult, {
legacySessionId: nextSessionState.legacySessionId,
});
}, normalizedUsage);
if (taskKey) {
if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) {
await clearTaskSessions(agent.companyId, agent.id, {
@@ -1676,10 +2013,41 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, "failed");
} finally {
await releaseRuntimeServicesForRun(run.id);
await startNextQueuedRunForAgent(agent.id);
}
} catch (outerErr) {
// Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun).
// The inner catch did not fire, so we must record the failure here.
const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure";
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
await setRunStatus(runId, "failed", {
error: message,
errorCode: "adapter_failed",
finishedAt: new Date(),
}).catch(() => undefined);
await setWakeupStatus(run.wakeupRequestId, "failed", {
finishedAt: new Date(),
error: message,
}).catch(() => undefined);
const failedRun = await getRun(runId).catch(() => null);
if (failedRun) {
// Emit a run-log event so the failure is visible in the run timeline,
// consistent with what the inner catch block does for adapter failures.
await appendRunEvent(failedRun, 1, {
eventType: "error",
stream: "system",
level: "error",
message,
}).catch(() => undefined);
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
}
// Ensure the agent is not left stuck in "running" if the inner catch handler's
// DB calls threw (e.g. a transient DB error in finalizeAgentStatus).
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
} finally {
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
activeRunExecutions.delete(run.id);
await startNextQueuedRunForAgent(run.agentId);
}
}
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
@@ -2425,6 +2793,8 @@ export function heartbeatService(db: Db) {
reapOrphanedRuns,
resumeQueuedRuns,
tickTimers: async (now = new Date()) => {
const allAgents = await db.select().from(agents);
let checked = 0;

View File

@@ -27,6 +27,7 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
function assertTransition(from: string, to: string) {
if (from === to) return;
@@ -1060,13 +1061,86 @@ export function issueService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null),
listComments: (issueId: string) =>
db
listComments: async (
issueId: string,
opts?: {
afterCommentId?: string | null;
order?: "asc" | "desc";
limit?: number | null;
},
) => {
const order = opts?.order === "asc" ? "asc" : "desc";
const afterCommentId = opts?.afterCommentId?.trim() || null;
const limit =
opts?.limit && opts.limit > 0
? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
: null;
const conditions = [eq(issueComments.issueId, issueId)];
if (afterCommentId) {
const anchor = await db
.select({
id: issueComments.id,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId)))
.then((rows) => rows[0] ?? null);
if (!anchor) return [];
conditions.push(
order === "asc"
? sql<boolean>`(
${issueComments.createdAt} > ${anchor.createdAt}
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
)`
: sql<boolean>`(
${issueComments.createdAt} < ${anchor.createdAt}
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id})
)`,
);
}
const query = db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt))
.then((comments) => comments.map(redactIssueComment)),
.where(and(...conditions))
.orderBy(
order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt),
order === "asc" ? asc(issueComments.id) : desc(issueComments.id),
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
},
getCommentCursor: async (issueId: string) => {
const [latest, countRow] = await Promise.all([
db
.select({
latestCommentId: issueComments.id,
latestCommentAt: issueComments.createdAt,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({
totalComments: sql<number>`count(*)::int`,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.then((rows) => rows[0] ?? null),
]);
return {
totalComments: Number(countRow?.totalComments ?? 0),
latestCommentId: latest?.latestCommentId ?? null,
latestCommentAt: latest?.latestCommentAt ?? null,
};
},
getComment: (commentId: string) =>
db

View File

@@ -1,5 +1,7 @@
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
const RUNTIME_BRANDING_BLOCK_START = "<!-- PAPERCLIP_RUNTIME_BRANDING_START -->";
const RUNTIME_BRANDING_BLOCK_END = "<!-- PAPERCLIP_RUNTIME_BRANDING_END -->";
const DEFAULT_FAVICON_LINKS = [
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
@@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
].join("\n");
const WORKTREE_FAVICON_LINKS = [
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
].join("\n");
export type WorktreeUiBranding = {
enabled: boolean;
name: string | null;
color: string | null;
textColor: string | null;
faviconHref: string | null;
};
function isTruthyEnvValue(value: string | undefined): boolean {
if (!value) return false;
@@ -21,21 +24,194 @@ function isTruthyEnvValue(value: string | undefined): boolean {
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function nonEmpty(value: string | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeHexColor(value: string | undefined): string | null {
const raw = nonEmpty(value);
if (!raw) return null;
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return null;
}
function hslComponentToHex(n: number): string {
return Math.round(Math.max(0, Math.min(255, n)))
.toString(16)
.padStart(2, "0");
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const s = Math.max(0, Math.min(100, saturation)) / 100;
const l = Math.max(0, Math.min(100, lightness)) / 100;
const c = (1 - Math.abs((2 * l) - 1)) * s;
const h = ((hue % 360) + 360) % 360;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - (c / 2);
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
}
function deriveColorFromSeed(seed: string): string {
let hash = 0;
for (const char of seed) {
hash = ((hash * 33) + char.charCodeAt(0)) >>> 0;
}
return hslToHex(hash % 360, 68, 56);
}
function hexToRgb(color: string): { r: number; g: number; b: number } {
const normalized = normalizeHexColor(color) ?? "#000000";
return {
r: Number.parseInt(normalized.slice(1, 3), 16),
g: Number.parseInt(normalized.slice(3, 5), 16),
b: Number.parseInt(normalized.slice(5, 7), 16),
};
}
function relativeLuminanceChannel(value: number): number {
const normalized = value / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance(color: string): number {
const { r, g, b } = hexToRgb(color);
return (
(0.2126 * relativeLuminanceChannel(r)) +
(0.7152 * relativeLuminanceChannel(g)) +
(0.0722 * relativeLuminanceChannel(b))
);
}
function pickReadableTextColor(background: string): string {
const backgroundLuminance = relativeLuminance(background);
const whiteContrast = 1.05 / (backgroundLuminance + 0.05);
const blackContrast = (backgroundLuminance + 0.05) / 0.05;
return whiteContrast >= blackContrast ? "#f8fafc" : "#111827";
}
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function createFaviconDataUrl(background: string, foreground: string): string {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">',
`<rect width="24" height="24" rx="6" fill="${background}"/>`,
`<path stroke="${foreground}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.15" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>`,
"</svg>",
].join("");
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
}
export function renderFaviconLinks(worktree: boolean): string {
return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS;
export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): WorktreeUiBranding {
if (!isWorktreeUiBrandingEnabled(env)) {
return {
enabled: false,
name: null,
color: null,
textColor: null,
faviconHref: null,
};
}
const name = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? "worktree";
const color = normalizeHexColor(env.PAPERCLIP_WORKTREE_COLOR) ?? deriveColorFromSeed(name);
const textColor = pickReadableTextColor(color);
return {
enabled: true,
name,
color,
textColor,
faviconHref: createFaviconDataUrl(color, textColor),
};
}
export function renderFaviconLinks(branding: WorktreeUiBranding): string {
if (!branding.enabled || !branding.faviconHref) return DEFAULT_FAVICON_LINKS;
const href = escapeHtmlAttribute(branding.faviconHref);
return [
`<link rel="icon" href="${href}" type="image/svg+xml" sizes="any" />`,
`<link rel="shortcut icon" href="${href}" type="image/svg+xml" />`,
].join("\n");
}
export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string {
if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return "";
return [
'<meta name="paperclip-worktree-enabled" content="true" />',
`<meta name="paperclip-worktree-name" content="${escapeHtmlAttribute(branding.name)}" />`,
`<meta name="paperclip-worktree-color" content="${escapeHtmlAttribute(branding.color)}" />`,
`<meta name="paperclip-worktree-text-color" content="${escapeHtmlAttribute(branding.textColor)}" />`,
].join("\n");
}
function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker);
if (start === -1 || end === -1 || end < start) return html;
const before = html.slice(0, start + startMarker.length);
const after = html.slice(end);
const indentedContent = content
? `\n${content
.split("\n")
.map((line) => ` ${line}`)
.join("\n")}\n `
: "\n ";
return `${before}${indentedContent}${after}`;
}
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
const start = html.indexOf(FAVICON_BLOCK_START);
const end = html.indexOf(FAVICON_BLOCK_END);
if (start === -1 || end === -1 || end < start) return html;
const before = html.slice(0, start + FAVICON_BLOCK_START.length);
const after = html.slice(end);
const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env));
return `${before}\n${links}\n ${after}`;
const branding = getWorktreeUiBranding(env);
const withFavicon = replaceMarkedBlock(html, FAVICON_BLOCK_START, FAVICON_BLOCK_END, renderFaviconLinks(branding));
return replaceMarkedBlock(
withFavicon,
RUNTIME_BRANDING_BLOCK_START,
RUNTIME_BRANDING_BLOCK_END,
renderRuntimeBrandingMeta(branding),
);
}

View File

@@ -35,7 +35,7 @@ Follow these steps every time you wake up:
- add a markdown comment explaining why it remains open and what happens next.
Always include links to the approval and issue in that comment.
**Step 3 — Get assignments.** `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,blocked`. Results sorted by priority. This is your inbox.
**Step 3 — Get assignments.** Prefer `GET /api/agents/me/inbox-lite` for the normal heartbeat inbox. It returns the compact assignment list you need for prioritization. Fall back to `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,blocked` only when you need the full issue objects.
**Step 4 — Pick work (with mention exception).** Work on `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
**Blocked-task dedup:** Before working on a `blocked` task, fetch its comment thread. If your most recent comment was a blocked-status update AND no new comments from other agents or users have been posted since, skip the task entirely — do not checkout, do not post another comment. Exit the heartbeat (or move to the next task) instead. Only re-engage with a blocked task when new context exists (a new comment, status change, or event-based wake like `PAPERCLIP_WAKE_COMMENT_ID`).
@@ -56,8 +56,15 @@ Headers: Authorization: Bearer $PAPERCLIP_API_KEY, X-Paperclip-Run-Id: $PAPERCLI
If already checked out by you, returns normally. If owned by another agent: `409 Conflict` — stop, pick a different task. **Never retry a 409.**
**Step 6 — Understand context.** `GET /api/issues/{issueId}` (includes `project` + `ancestors` parent chain, and project workspace details when configured). `GET /api/issues/{issueId}/comments`. Read ancestors to understand _why_ this task exists.
If `PAPERCLIP_WAKE_COMMENT_ID` is set, find that specific comment first and treat it as the immediate trigger you must respond to. Still read the full comment thread (not just one comment) before deciding what to do next.
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
Use comments incrementally:
- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}`
- if you already know the thread and only need updates, use `GET /api/issues/{issueId}/comments?after={last-seen-comment-id}&order=asc`
- use the full `GET /api/issues/{issueId}/comments` route only when you are cold-starting, when session memory is unreliable, or when the incremental path is not enough
Read enough ancestor/comment context to understand _why_ the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat.
**Step 7 — Do the work.** Use your tools and capabilities.
@@ -226,10 +233,13 @@ PATCH /api/agents/{agentId}/instructions-path
| Action | Endpoint |
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
| My identity | `GET /api/agents/me` |
| My compact inbox | `GET /api/agents/me/inbox-lite` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
| Get comments | `GET /api/issues/:issueId/comments` |
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Add comment | `POST /api/issues/:issueId/comments` |

View File

@@ -8,6 +8,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" />
<title>Paperclip</title>
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />

View File

@@ -444,23 +444,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/>
</Field>
{isLocal && (
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
</div>
</>
)}
</div>
</div>
@@ -576,19 +581,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
{isLocal && isCreate && (
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
</div>
</>
)}
{/* Adapter-specific fields */}
@@ -704,6 +714,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}}
/>
</Field>
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
</div>
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}

View File

@@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
@@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const userLabel = (userId: string | null | undefined) =>
userId
? userId === "local-board"
? "Board"
: currentUserId && userId === currentUserId
? "Me"
: userId.slice(0, 5)
: null;
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
@@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
>
No assignee
</button>
{issue.createdByUserId && (
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
@@ -361,7 +370,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{sortedAgents

View File

@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
@@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
let result = issues;
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId));
if (state.assignees.length > 0) {
result = result.filter((issue) => {
for (const assignee of state.assignees) {
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
if (issue.assigneeAgentId === assignee) return true;
}
return false;
});
}
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
return result;
}
@@ -165,6 +176,11 @@ export function IssuesList({
}: IssuesListProps) {
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
// Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@@ -224,9 +240,9 @@ export function IssuesList({
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState);
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -253,13 +269,21 @@ export function IssuesList({
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
}
// assignee
const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned");
const groups = groupBy(
filtered,
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
);
return Object.keys(groups).map((key) => ({
key,
label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)),
label:
key === "__unassigned"
? "Unassigned"
: key.startsWith("__user:")
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
: (agentName(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
const newIssueDefaults = (groupKey?: string) => {
const defaults: Record<string, string> = {};
@@ -267,13 +291,16 @@ export function IssuesList({
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
else defaults.assigneeAgentId = groupKey;
}
}
return defaults;
};
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null);
setAssigneeSearch("");
};
@@ -419,22 +446,37 @@ export function IssuesList({
</div>
{/* Assignee */}
{agents && agents.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{agents.map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__unassigned")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId && (
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__me")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
)}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
)}
</div>
{labels && labels.length > 0 && (
<div className="space-y-1">
@@ -683,6 +725,13 @@ export function IssuesList({
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
@@ -701,7 +750,7 @@ export function IssuesList({
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search agents..."
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
@@ -710,16 +759,32 @@ export function IssuesList({
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
@@ -737,7 +802,7 @@ export function IssuesList({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />

View File

@@ -5,7 +5,6 @@ import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { SidebarNavItem } from "./SidebarNavItem";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@@ -15,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -23,6 +23,7 @@ import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
@@ -37,6 +38,7 @@ export function Layout() {
loading: companiesLoading,
selectedCompany,
selectedCompanyId,
selectionSource,
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
@@ -89,7 +91,13 @@ export function Layout() {
return;
}
if (selectedCompanyId !== matchedCompany.id) {
if (
shouldSyncCompanySelectionFromRoute({
selectionSource,
selectedCompanyId,
routeCompanyId: matchedCompany.id,
})
) {
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
}
}, [
@@ -100,6 +108,7 @@ export function Layout() {
location.pathname,
location.search,
navigate,
selectionSource,
selectedCompanyId,
setSelectedCompanyId,
]);
@@ -215,7 +224,7 @@ export function Layout() {
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)}
>
<a
@@ -224,139 +233,148 @@ export function Layout() {
>
Skip to Main Content
</a>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
)}
<WorktreeBanner />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
)}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col shrink-0 h-full">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{/* Main content */}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<Outlet />
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
) : (
<div className="flex h-full flex-col shrink-0">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{/* Main content */}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
</main>
<PropertiesPanel />
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
className={cn(
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<Outlet />
)}
</main>
<PropertiesPanel />
</div>
</div>
</div>
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}

View File

@@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
parseAssigneeValue,
} from "../lib/assignees";
import {
Dialog,
DialogContent,
@@ -63,7 +68,8 @@ interface IssueDraft {
description: string;
status: string;
priority: string;
assigneeId: string;
assigneeValue: string;
assigneeId?: string;
projectId: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
@@ -173,7 +179,7 @@ export function NewIssueDialog() {
const [description, setDescription] = useState("");
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [assigneeValue, setAssigneeValue] = useState("");
const [projectId, setProjectId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
@@ -220,7 +226,11 @@ export function NewIssueDialog() {
userId: currentUserId,
});
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
);
@@ -295,7 +305,7 @@ export function NewIssueDialog() {
description,
status,
priority,
assigneeId,
assigneeValue,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
@@ -307,7 +317,7 @@ export function NewIssueDialog() {
description,
status,
priority,
assigneeId,
assigneeValue,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
@@ -330,7 +340,7 @@ export function NewIssueDialog() {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
@@ -340,7 +350,11 @@ export function NewIssueDialog() {
setDescription(draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
setAssigneeValue(
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
);
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
@@ -350,7 +364,7 @@ export function NewIssueDialog() {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
@@ -390,7 +404,7 @@ export function NewIssueDialog() {
setDescription("");
setStatus("todo");
setPriority("");
setAssigneeId("");
setAssigneeValue("");
setProjectId("");
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
@@ -406,7 +420,7 @@ export function NewIssueDialog() {
function handleCompanyChange(companyId: string) {
if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId);
setAssigneeId("");
setAssigneeValue("");
setProjectId("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
@@ -443,7 +457,8 @@ export function NewIssueDialog() {
description: description.trim() || undefined,
status,
priority: priority || "medium",
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
@@ -475,7 +490,9 @@ export function NewIssueDialog() {
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentAssignee = selectedAssigneeAgentId
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
: null;
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
@@ -497,16 +514,18 @@ export function NewIssueDialog() {
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
() => [
...currentUserAssigneeOption(currentUserId),
...sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
],
[agents, currentUserId, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
@@ -710,7 +729,16 @@ export function NewIssueDialog() {
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
assigneeSelectorRef.current?.focus();
if (assigneeValue) {
// Assignee already set — skip to project or description
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
@@ -723,33 +751,49 @@ export function NewIssueDialog() {
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={assigneeId}
value={assigneeValue}
options={assigneeOptions}
placeholder="Assignee"
disablePortal
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
onChange={(value) => {
const nextAssignee = parseAssigneeValue(value);
if (nextAssignee.assigneeAgentId) {
trackRecentAssignee(nextAssignee.assigneeAgentId);
}
setAssigneeValue(value);
}}
onConfirm={() => {
projectSelectorRef.current?.focus();
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option && currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
</>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
const assignee = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return (
<>
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@@ -494,23 +494,41 @@ export function OnboardingWizard() {
}
async function handleStep3Next() {
if (!createdCompanyId || !createdAgentId) return;
setError(null);
setStep(4);
}
async function handleLaunch() {
if (!createdCompanyId || !createdAgentId) return;
setLoading(true);
setError(null);
try {
const issue = await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(),
...(taskDescription.trim()
? { description: taskDescription.trim() }
: {}),
assigneeAgentId: createdAgentId,
status: "todo"
});
setCreatedIssueRef(issue.identifier ?? issue.id);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
setStep(4);
let issueRef = createdIssueRef;
if (!issueRef) {
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({
queryKey: queryKeys.issues.list(createdCompanyId)
});
}
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(
createdCompanyPrefix
? `/${createdCompanyPrefix}/issues/${issueRef}`
: `/issues/${issueRef}`
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create task");
} finally {
@@ -518,20 +536,6 @@ export function OnboardingWizard() {
}
}
async function handleLaunch() {
if (!createdAgentId) return;
setLoading(true);
setError(null);
setLoading(false);
reset();
closeOnboarding();
if (createdCompanyPrefix) {
navigate(`/${createdCompanyPrefix}/dashboard`);
return;
}
navigate("/dashboard");
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@@ -1175,8 +1179,8 @@ export function OnboardingWizard() {
<div>
<h3 className="font-medium">Ready to launch</h3>
<p className="text-xs text-muted-foreground">
Everything is set up. Your assigned task already woke
the agent, so you can jump straight to the issue.
Everything is set up. Launching now will create the
starter task, wake the agent, and open the issue.
</p>
</div>
</div>
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() {
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Opening..." : "Open Issue"}
{loading ? "Creating..." : "Create & Open Issue"}
</Button>
)}
</div>

View File

@@ -0,0 +1,25 @@
import { getWorktreeUiBranding } from "../lib/worktree-branding";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
if (!branding) return null;
return (
<div
className="relative overflow-hidden border-b px-3 py-1.5 text-[11px] font-medium tracking-[0.2em] uppercase"
style={{
backgroundColor: branding.color,
color: branding.textColor,
borderColor: `${branding.textColor}22`,
boxShadow: `inset 0 -1px 0 ${branding.textColor}18`,
backgroundImage: `linear-gradient(90deg, ${branding.textColor}14, transparent 28%, transparent 72%, ${branding.textColor}12), repeating-linear-gradient(135deg, transparent 0 10px, ${branding.textColor}08 10px 20px)`,
}}
>
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
<span className="shrink-0 opacity-70">Worktree</span>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
</div>
</div>
);
}

View File

@@ -26,7 +26,7 @@ export const help: Record<string, string> = {
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
chrome: "Enable Claude's Chrome integration by passing --chrome.",
@@ -44,7 +44,7 @@ export const help: Record<string, string> = {
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.",
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",

View File

@@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared";
import { companiesApi } from "../api/companies";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
import type { CompanySelectionSource } from "../lib/company-selection";
type CompanySelectionOptions = { source?: CompanySelectionSource };
interface CompanyContextValue {

View File

@@ -5,6 +5,7 @@ interface NewIssueDefaults {
priority?: string;
projectId?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
title?: string;
description?: string;
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
getRememberedPathOwnerCompanyId,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const companies = [
{ id: "for", issuePrefix: "FOR" },
{ id: "pap", issuePrefix: "PAP" },
];
describe("getRememberedPathOwnerCompanyId", () => {
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBe("for");
});
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
expect(
getRememberedPathOwnerCompanyId({
companies: [],
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBeNull();
});
it("falls back to the previous company for unprefixed board routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/dashboard",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
});
describe("sanitizeRememberedPathForCompany", () => {
it("keeps remembered issue paths that belong to the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/PAP-12",
companyPrefix: "PAP",
}),
).toBe("/issues/PAP-12");
});
it("falls back to dashboard for remembered issue identifiers from another company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/FOR-1",
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
it("falls back to dashboard when no remembered path exists", () => {
expect(
sanitizeRememberedPathForCompany({
path: null,
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
});

View File

@@ -1,10 +1,14 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { toCompanyRelativePath } from "../lib/company-routes";
import {
getRememberedPathOwnerCompanyId,
isRememberableCompanyPath,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const STORAGE_KEY = "paperclip.companyPaths";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
function getCompanyPaths(): Record<string, string> {
try {
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
}
function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return true;
const [root] = segments;
if (GLOBAL_SEGMENTS.has(root!)) return false;
return true;
}
/**
* Remembers the last visited page per company and navigates to it on company switch.
* Falls back to /dashboard if no page was previously visited for a company.
*/
export function useCompanyPageMemory() {
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation();
const navigate = useNavigate();
const prevCompanyId = useRef<string | null>(selectedCompanyId);
const rememberedPathOwnerCompanyId = useMemo(
() =>
getRememberedPathOwnerCompanyId({
companies,
pathname: location.pathname,
fallbackCompanyId: prevCompanyId.current,
}),
[companies, location.pathname],
);
// Save current path for current company on every location change.
// Uses prevCompanyId ref so we save under the correct company even
// during the render where selectedCompanyId has already changed.
const fullPath = location.pathname + location.search;
useEffect(() => {
const companyId = prevCompanyId.current;
const companyId = rememberedPathOwnerCompanyId;
const relativePath = toCompanyRelativePath(fullPath);
if (companyId && isRememberableCompanyPath(relativePath)) {
saveCompanyPath(companyId, relativePath);
}
}, [fullPath]);
}, [fullPath, rememberedPathOwnerCompanyId]);
// Navigate to saved path when company changes
useEffect(() => {
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
) {
if (selectionSource !== "route_sync" && selectedCompany) {
const paths = getCompanyPaths();
const savedPath = paths[selectedCompanyId];
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
const targetPath = sanitizeRememberedPathForCompany({
path: paths[selectedCompanyId],
companyPrefix: selectedCompany.issuePrefix,
});
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
}
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
formatAssigneeUserLabel,
parseAssigneeValue,
} from "./assignees";
describe("assignee selection helpers", () => {
it("encodes and parses agent assignees", () => {
const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" });
expect(value).toBe("agent:agent-123");
expect(parseAssigneeValue(value)).toEqual({
assigneeAgentId: "agent-123",
assigneeUserId: null,
});
});
it("encodes and parses current-user assignees", () => {
const [option] = currentUserAssigneeOption("local-board");
expect(option).toEqual({
id: "user:local-board",
label: "Me",
searchText: "me board human local-board",
});
expect(parseAssigneeValue(option.id)).toEqual({
assigneeAgentId: null,
assigneeUserId: "local-board",
});
});
it("treats an empty selection as no assignee", () => {
expect(parseAssigneeValue("")).toEqual({
assigneeAgentId: null,
assigneeUserId: null,
});
});
it("keeps backward compatibility for raw agent ids in saved drafts", () => {
expect(parseAssigneeValue("legacy-agent-id")).toEqual({
assigneeAgentId: "legacy-agent-id",
assigneeUserId: null,
});
});
it("formats current and board user labels consistently", () => {
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
});
});

51
ui/src/lib/assignees.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface AssigneeSelection {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface AssigneeOption {
id: string;
label: string;
searchText?: string;
}
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
return "";
}
export function parseAssigneeValue(value: string): AssigneeSelection {
if (!value) {
return { assigneeAgentId: null, assigneeUserId: null };
}
if (value.startsWith("agent:")) {
const assigneeAgentId = value.slice("agent:".length);
return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null };
}
if (value.startsWith("user:")) {
const assigneeUserId = value.slice("user:".length);
return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null };
}
// Backward compatibility for older drafts/defaults that stored a raw agent id.
return { assigneeAgentId: value, assigneeUserId: null };
}
export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] {
if (!currentUserId) return [];
return [{
id: assigneeValueFromSelection({ assigneeUserId: currentUserId }),
label: "Me",
searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`,
}];
}
export function formatAssigneeUserLabel(
userId: string | null | undefined,
currentUserId: string | null | undefined,
): string | null {
if (!userId) return null;
if (currentUserId && userId === currentUserId) return "Me";
if (userId === "local-board") return "Board";
return userId.slice(0, 5);
}

View File

@@ -0,0 +1,65 @@
import {
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
toCompanyRelativePath,
} from "./company-routes";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
export function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return true;
const [root] = segments;
if (GLOBAL_SEGMENTS.has(root!)) return false;
return true;
}
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
companyPrefix: string;
}): T | null {
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
}
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
pathname: string;
fallbackCompanyId: string | null;
}): string | null {
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
if (!routeCompanyPrefix) {
return params.fallbackCompanyId;
}
return findCompanyByPrefix({
companies: params.companies,
companyPrefix: routeCompanyPrefix,
})?.id ?? null;
}
export function sanitizeRememberedPathForCompany(params: {
path: string | null | undefined;
companyPrefix: string;
}): string {
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
if (!isRememberableCompanyPath(relativePath)) {
return "/dashboard";
}
const pathname = relativePath.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
const [root, entityId] = segments;
if (root === "issues" && entityId) {
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
if (
identifierMatch &&
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
) {
return "/dashboard";
}
}
return relativePath;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { shouldSyncCompanySelectionFromRoute } from "./company-selection";
describe("shouldSyncCompanySelectionFromRoute", () => {
it("does not resync when selection already matches the route", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "pap",
}),
).toBe(false);
});
it("defers route sync while a manual company switch is in flight", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "manual",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(false);
});
it("syncs back to the route company for non-manual mismatches", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
export function shouldSyncCompanySelectionFromRoute(params: {
selectionSource: CompanySelectionSource;
selectedCompanyId: string | null;
routeCompanyId: string;
}): boolean {
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
if (selectedCompanyId === routeCompanyId) return false;
// Let manual company switches finish their remembered-path navigation first.
if (selectionSource === "manual" && selectedCompanyId) {
return false;
}
return true;
}

View File

@@ -0,0 +1,65 @@
export type WorktreeUiBranding = {
enabled: true;
name: string;
color: string;
textColor: string;
};
function readMetaContent(name: string): string | null {
if (typeof document === "undefined") return null;
const element = document.querySelector(`meta[name="${name}"]`);
const content = element?.getAttribute("content")?.trim();
return content ? content : null;
}
function normalizeHexColor(value: string | null): string | null {
if (!value) return null;
const hex = value.startsWith("#") ? value.slice(1) : value;
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return null;
}
function hexToRgb(color: string): { r: number; g: number; b: number } {
const normalized = normalizeHexColor(color) ?? "#000000";
return {
r: Number.parseInt(normalized.slice(1, 3), 16),
g: Number.parseInt(normalized.slice(3, 5), 16),
b: Number.parseInt(normalized.slice(5, 7), 16),
};
}
function relativeLuminanceChannel(value: number): number {
const normalized = value / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function pickReadableTextColor(background: string): string {
const { r, g, b } = hexToRgb(background);
const luminance =
(0.2126 * relativeLuminanceChannel(r)) +
(0.7152 * relativeLuminanceChannel(g)) +
(0.0722 * relativeLuminanceChannel(b));
const whiteContrast = 1.05 / (luminance + 0.05);
const blackContrast = (luminance + 0.05) / 0.05;
return whiteContrast >= blackContrast ? "#f8fafc" : "#111827";
}
export function getWorktreeUiBranding(): WorktreeUiBranding | null {
if (readMetaContent("paperclip-worktree-enabled") !== "true") return null;
const name = readMetaContent("paperclip-worktree-name");
const color = normalizeHexColor(readMetaContent("paperclip-worktree-color"));
if (!name || !color) return null;
return {
enabled: true,
name,
color,
textColor: normalizeHexColor(readMetaContent("paperclip-worktree-text-color")) ?? pickReadableTextColor(color),
};
}

View File

@@ -304,8 +304,7 @@ export function IssueDetail() {
options.push({ id: `agent:${agent.id}`, label: agent.name });
}
if (currentUserId) {
const label = currentUserId === "local-board" ? "Board" : "Me (Board)";
options.push({ id: `user:${currentUserId}`, label });
options.push({ id: `user:${currentUserId}`, label: "Me" });
}
return options;
}, [agents, currentUserId]);