Compare commits

..

280 Commits

Author SHA1 Message Date
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
Dotta
56df8d3cf0 Merge pull request #707 from paperclipai/nm/premerge-lockfile-refresh
ci: refresh pnpm lockfile before merge
2026-03-12 10:55:25 -05:00
Dotta
8808a33fe1 ci: refresh pnpm lockfile before merge 2026-03-12 10:52:17 -05:00
Dotta
ac82cae39a Merge pull request #706 from zvictor/fix-gemini-build
fix(docker): include gemini adapter manifest in deps stage
2026-03-12 10:38:13 -05:00
zvictor
9c6a913ef1 fix(docker): include gemini adapter manifest in deps stage 2026-03-12 12:28:45 -03:00
Dotta
18f7092b71 Merge pull request #430 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-12 09:39:22 -05:00
Dotta
c8b08e64d6 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Raise default max turns to 300
2026-03-12 09:37:15 -05:00
lockfile-bot
e6ff4eb8b2 chore(lockfile): refresh pnpm-lock.yaml 2026-03-12 14:36:52 +00:00
Dotta
7adc14ab50 Merge pull request #701 from paperclipai/nm/raise-default-max-turns-300
Raise default max turns to 300
2026-03-12 09:36:28 -05:00
Dotta
aeafeba12b Raise default max turns to 300
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 09:34:05 -05:00
Dotta
890ff39bdb Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Drop pnpm lockfile from PR
  Update ui/src/components/OnboardingWizard.tsx
  Update ui/src/components/OnboardingWizard.tsx
  Fix onboarding manual debug JSX
  Improve onboarding defaults and issue goal fallback
  Simplify adapter environment check: animate pass, show debug only on fail
  Show Claude Code and Codex as recommended, collapse other adapter types
  Animate onboarding layout when switching between Company and Agent steps
  Make onboarding wizard steps clickable tabs for easier dev navigation
  Add agent chat architecture plan
  Style tweaks for onboarding wizard step 1
  Add direct onboarding routes
2026-03-12 09:24:57 -05:00
Dotta
55c145bff2 Merge pull request #700 from paperclipai/paperclip-better-onboarding
Improve onboarding flow and issue goal fallback
2026-03-12 09:24:11 -05:00
Dotta
7809405e8f Drop pnpm lockfile from PR 2026-03-12 09:19:28 -05:00
Dotta
88916fd11b Update ui/src/components/OnboardingWizard.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 09:15:40 -05:00
Dotta
06b50ba161 Update ui/src/components/OnboardingWizard.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 09:15:20 -05:00
Dotta
f76a7ef408 Fix onboarding manual debug JSX 2026-03-12 08:54:27 -05:00
Dotta
448e9c192b Improve onboarding defaults and issue goal fallback 2026-03-12 08:50:31 -05:00
Dotta
1d5e5247e8 Raise default max turns to 300
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:42:12 -05:00
Dotta
5f3f354b3a Simplify adapter environment check: animate pass, show debug only on fail
- When the adapter probe passes, show a clean animated "Passed" badge with
  a checkmark icon instead of verbose check details
- Only show the "Manual debug" section when the test fails, reducing noise
  for successful flows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:42:07 -05:00
Dotta
7df74b170d Show Claude Code and Codex as recommended, collapse other adapter types
In the onboarding wizard step 2 ("Create your first agent"), Claude Code and
Codex are now shown prominently as recommended options. Other adapter types
(OpenCode, Pi, Cursor, OpenClaw Gateway) are hidden behind a collapsible
"More Agent Adapter Types" toggle to reduce visual noise for new users.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:41:22 -05:00
Dotta
7e6a5682fa Animate onboarding layout when switching between Company and Agent steps
When on the Company step (step 1), the form takes up the left half with
ASCII art on the right. Switching to Agent or any other step smoothly
animates the form to full width while fading out the ASCII art panel.
Switching back reverses the animation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:39:41 -05:00
Dotta
e6a684d96a Make onboarding wizard steps clickable tabs for easier dev navigation
Replace the progress bar dots with labeled tab buttons (Company, Agent,
Task, Launch) that allow clicking directly to any step. This makes it
easy to debug/preview individual onboarding screens without stepping
through the full flow.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:39:41 -05:00
Dotta
c3cf4279fa Add agent chat architecture plan
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:39:41 -05:00
Dotta
d4d1b2e7f9 Style tweaks for onboarding wizard step 1
- Set animation panel (right half) background to #1d1d1d
- Add more margin above Company name form label
- Make form labels white when input is focused or has value
  using Tailwind group/focus-within pattern

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:39:41 -05:00
Dotta
b7744a2215 Add direct onboarding routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:39:41 -05:00
Dotta
f5c766beb9 Merge pull request #697 from paperclipai/paperclip_instance_sidebar_v2
Add instance heartbeat settings sidebar
2026-03-12 08:19:52 -05:00
Dotta
3e8993b449 Remove pnpm lockfile changes from sidebar PR 2026-03-12 08:19:16 -05:00
Dotta
32bdcf1dca Add instance heartbeat settings sidebar 2026-03-12 08:14:45 -05:00
Dotta
369dfa4397 Fix hooks order violation and UX copy on instance settings page
Move useMemo and derived state above early returns so hooks are always
called in the same order. Simplify the description to plain English
and change toggle button labels to "Enable Timer Heartbeat" /
"Disable Timer Heartbeat" for clarity.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:14:45 -05:00
Dotta
905403c1af Compact grouped heartbeat list on instance settings page
Group agents by company with a single card per company and dense
inline rows instead of one card per agent. Replaces the three stat
cards with a slim inline summary. Each row shows status badge, linked
agent name, role, interval, last heartbeat time, a config link icon,
and an enable/disable button.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 08:14:45 -05:00
Dotta
dc3f3776ea Merge pull request #695 from paperclipai/nm/public-master-polish-batch
Polish inbox, transcripts, and log redaction flows
2026-03-12 08:13:33 -05:00
Dotta
44396be7c1 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Default Gemini adapter to yolo mode and add API access prompt note
  fix: remove Cmd+1..9 company-switch shortcut
  fix(ui): prevent IME composition Enter from moving focus in new issue title
  fix(cli): add restart hint after allowed-hostname change
  docs: remove obsolete TODO for CONTRIBUTING.md
  fix: default dangerouslySkipPermissions to true for unattended agents
  fix: route heartbeat cost recording through costService
  Show issue creator in properties sidebar
2026-03-12 08:09:06 -05:00
Dotta
c49e5e90be Merge pull request #656 from aaaaron/gemini-tool-permissions
Default Gemini adapter to yolo mode, add API access note
2026-03-12 07:59:14 -05:00
Dotta
01180d3027 Move maintainer skills into .agents/skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 07:36:14 -05:00
Dotta
397e6d0915 Document worktree CLI commands with full option reference
Add a "Worktree CLI Reference" subsection to doc/DEVELOPING.md with
complete option tables and examples for worktree init, worktree:make,
and worktree env subcommands.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 07:31:14 -05:00
Dotta
778afd31b1 Tighten dashboard agent pane typography
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 07:02:06 -05:00
Dotta
6fe7f7a510 Hide saved-session resume noise from nice transcripts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-12 06:59:25 -05:00
Dotta
088eaea0cb Redact current user in comments and token checks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 22:17:21 -05:00
Dotta
b1bf09970f Render transcript markdown and fold command stdout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 21:51:23 -05:00
Dotta
6540084ddf Add inbox live badge and timestamp
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 21:47:21 -05:00
Dotta
cde3a8c604 Move inbox read action beside tabs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 21:43:16 -05:00
Dotta
57113b1075 Merge pull request #386 from domocarroll/fix/heartbeat-budget-enforcement
fix: route heartbeat cost recording through costService
2026-03-11 21:30:09 -05:00
Dotta
cbe5cfe603 Merge pull request #443 from adamrobbie-nudge/docs/remove-contributing-todo
docs: remove obsolete TODO for CONTRIBUTING.md
2026-03-11 21:16:29 -05:00
Dotta
833ccb9921 Merge pull request #549 from mvanhorn/osc/538-fix-allowed-hostname-restart-hint
fix(cli): add restart hint after allowed-hostname change
2026-03-11 21:15:49 -05:00
Dotta
bfbb42a9fc Merge pull request #578 from kaonash/fix_ime_composition_enter_in_new_issue_dialog
fix(ui): prevent IME composition Enter from moving focus in new issue title
2026-03-11 21:14:39 -05:00
Dotta
c4e64be4bc Merge pull request #628 from STRML/fix/remove-cmd-number-shortcut
fix: remove Cmd+1..9 company-switch shortcut
2026-03-11 21:12:10 -05:00
Dotta
88b47c805c Merge pull request #145 from cschneid/show-issue-creator
Show issue creator in properties sidebar
2026-03-11 21:11:00 -05:00
Dotta
908e01655a Merge pull request #388 from ohld/fix/default-skip-permissions
fix: default dangerouslySkipPermissions to true
2026-03-11 21:07:03 -05:00
Dotta
ea54c018ad Match inbox row edges to issues
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 21:05:13 -05:00
Dotta
6c351cb37d Use issues page as issue row baseline
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 20:59:34 -05:00
Dotta
ee3d8c1890 Redact home paths in transcript views
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 20:56:47 -05:00
Dotta
3b9da0ee95 Refactor shared issue rows
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 20:51:28 -05:00
Aaron
6bfe0b8422 Default Gemini adapter to yolo mode and add API access prompt note
Gemini CLI only registers run_shell_command in --approval-mode yolo.
Non-yolo modes don't expose it at all, making Paperclip API calls
impossible. Always pass --approval-mode yolo and remove the now-unused
policy engine code, approval mode config, and UI toggles.

Add a "Paperclip API access note" to the prompt with curl examples
via run_shell_command, since the universal SKILL.md is tool-agnostic.

Also extract structured question events from Gemini assistant messages
to support interactive approval flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:45:08 +00:00
Dotta
33c6d093ab Adjust inbox issue rows on mobile
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 20:37:42 -05:00
Dotta
d0b1079b9b Remove new issue draft autosave footer copy
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 20:37:36 -05:00
Dotta
7945e7e780 Redact current user from run logs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 17:46:23 -05:00
Dotta
6e7266eeb4 Merge pull request #649 from paperclipai/fix/issue-runs-heartbeat-regressions
Fix issue run lookup and heartbeat run summaries
2026-03-11 17:26:43 -05:00
Dotta
d19ff3f4dd Fix issue run lookup and heartbeat run summaries 2026-03-11 17:23:33 -05:00
Dotta
4435e14838 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  Tighten transcript label styling
  Fix env-sensitive worktree and runtime config tests
  Refine executed command row centering
  Tighten live run transcript streaming and stdout
  Center collapsed command group rows
  Refine collapsed command failure styling
  Tighten command transcript rows and dashboard card
  Polish transcript event widgets
  Refine transcript chrome and labels
  fix: remove paperclip property from OpenClaw Gateway agent params
  Add a run transcript UX fixture lab
  Humanize run transcripts across run detail and live surfaces
  fix(adapters/gemini-local): address PR review feedback
  fix(adapters/gemini-local): inject skills into ~/.gemini/ instead of tmpdir
  fix(adapters/gemini-local): downgrade missing API key to info level
  feat(adapters/gemini-local): add auth detection, turn-limit handling, sandbox, and approval modes
  fix(adapters/gemini-local): address PR review feedback for skills and formatting
  feat(adapters): add Gemini CLI local adapter support

# Conflicts:
#	cli/src/__tests__/worktree.test.ts
2026-03-11 17:04:30 -05:00
Dotta
df121c61dc Merge pull request #648 from paperclipai/paperclip-nicer-runlogs-formats
Humanize run transcripts and polish transcript UX
2026-03-11 17:02:33 -05:00
Dotta
1f204e4d76 Fix issue description overflow with break-words and overflow-hidden
Long unbroken text (stack traces, file paths, URLs) in issue descriptions
could overflow the container. Add break-words to MarkdownBody and
overflow-hidden to InlineEditor display wrapper.

Fixes PAP-476

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 16:41:14 -05:00
Dotta
8194132996 Tighten transcript label styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 16:40:26 -05:00
Dotta
f7cc292742 Fix env-sensitive worktree and runtime config tests
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 16:38:31 -05:00
Dotta
2efc3a3ef6 Fix worktree JWT env persistence
Ensure worktree init writes PAPERCLIP_AGENT_JWT_SECRET into the new .paperclip/.env when the source instance already has a usable secret loaded or configured. Also harden the affected integration tests against shell env leakage and full-suite timeout pressure.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 16:38:16 -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
User
bb6e721567 fix: remove Cmd+1..9 company-switch shortcut
This shortcut interfered with browser tab-switching (Cmd+1..9) and
produced a black screen when used. Removes the handler, the Layout
callback, and the design-guide documentation entry.

Closes RUS-56
2026-03-11 15:32:39 -04:00
Dotta
e76adf6ed1 Refine executed command row centering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 13:58:24 -05:00
Dotta
2b4d82bfdd Merge pull request #452 from aaaaron/feat/gemini-adapter-improvements
feat(adapters/gemini-local): Gemini CLI adapter with auth, skills, and sandbox support
2026-03-11 13:43:28 -05:00
Dotta
5e9c223077 Tighten live run transcript streaming and stdout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 13:29:40 -05:00
Dotta
98ede67b9b Center collapsed command group rows
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 13:24:45 -05:00
Dotta
f594edd39f Refine collapsed command failure styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 13:21:58 -05:00
Dotta
487c86f58e Tighten command transcript rows and dashboard card
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 13:14:08 -05:00
Dotta
b3e71ca562 Polish transcript event widgets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 12:14:12 -05:00
Dotta
ab2f9e90eb Refine transcript chrome and labels
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 11:45:05 -05:00
Dotta
cb77b2eb7e Merge pull request #626 from openagen/fix/606-remove-paperclip-property
fix: remove paperclip property from OpenClaw Gateway agent params
2026-03-11 11:39:55 -05:00
lin
6c9e639a68 fix: remove paperclip property from OpenClaw Gateway agent params
The OpenClaw Gateway's agent method has strict parameter validation
that rejects unknown properties. The paperclip property was being
sent at the root level of agentParams, causing validation failures
with error: "invalid agent params: at root: unexpected property 'paperclip'"

The paperclip metadata is already included in the message field
via wakeText, so removing the separate paperclip property resolves
the validation error while preserving the necessary information.

Fixes #606

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 00:27:41 +08:00
Dotta
6e4694716b Add a run transcript UX fixture lab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 10:47:22 -05:00
Dotta
87b8e21701 Humanize run transcripts across run detail and live surfaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-11 10:35:41 -05:00
Dotta
dd5d2c7c92 Merge pull request #618 from paperclipai/skill/update-paperclip-skill-md
Update paperclip skill: co-author rule + markdown fixes
2026-03-11 09:53:15 -05:00
Dotta
e168dc7b97 Update paperclip skill: add co-author rule and fix markdown formatting
Add commit co-author requirement for Paperclip agents and fix markdown
lint issues (blank lines before lists, table column alignment).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:52:21 -05:00
Dotta
4670f60d3e Merge pull request #616 from paperclipai/public/worktree-pnpm-install
Install dependencies after creating worktree
2026-03-11 09:32:26 -05:00
Dotta
472322de24 Install dependencies after creating worktree
Run pnpm install in the new worktree directory before initializing the
Paperclip instance so that node_modules are ready when the init step runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:31:41 -05:00
Dotta
3770e94d56 Merge pull request #613 from paperclipai/public/inbox-runs-worktree-history
Polish inbox workflows, agent runs, and worktree setup
2026-03-11 09:21:51 -05:00
Dotta
d9492f02d6 Add worktree start-point support 2026-03-11 09:15:27 -05:00
Dotta
57d8d01079 Align inbox badge with visible unread items 2026-03-11 09:02:23 -05:00
Dotta
345c7f4a88 Remove inbox recent issues label 2026-03-11 08:51:30 -05:00
Dotta
521b24da3d Tighten recent inbox tab behavior 2026-03-11 08:42:41 -05:00
Dotta
96e03b45b9 Refine inbox tabs and layout 2026-03-11 08:26:41 -05:00
Dotta
57dcdb51af ui: apply interface polish from design article review
- Add global font smoothing (antialiased) to body
- Add tabular-nums to all numeric displays: MetricCard values, Costs page,
  AgentDetail token/cost grids and tables, IssueDetail cost summary,
  Companies page budget display
- Replace markdown image hard border with subtle inset box-shadow overlay
- Replace all animate-ping status dots with calmer animate-pulse across
  AgentDetail, IssueDetail, Agents, sidebar, kanban, issues list, and
  active agents panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:20:24 -05:00
Dotta
a503d2c12c Adjust inbox tab memory and badge counts 2026-03-11 07:42:19 -05:00
Dotta
21d2b075e7 Fix inbox badge logic and landing view 2026-03-10 22:55:45 -05:00
Ken Shimizu
426b16987a fix(ui): prevent IME composition Enter from moving focus in new issue title 2026-03-11 11:57:46 +09:00
Dotta
92aef9bae8 Slim heartbeat run list payloads 2026-03-10 21:16:33 -05:00
Dotta
5f76d03913 ui: smooth new issue submit state 2026-03-10 21:06:16 -05:00
Dotta
d3ac8722be Add agent runs tab to detail page 2026-03-10 21:06:15 -05:00
Dotta
183d71eb7c Restore native mobile page scrolling 2026-03-10 21:06:10 -05:00
Dotta
3273692944 Fix markdown link dialog positioning 2026-03-10 21:01:47 -05:00
Dotta
b5935349ed Preserve issue breadcrumb source 2026-03-10 20:59:55 -05:00
Dotta
4b49efa02e Smooth agent config save button state 2026-03-10 20:58:18 -05:00
Dotta
c2c63868e9 Refine issue markdown typography 2026-03-10 20:55:41 -05:00
Matt Van Horn
9d2800e691 fix(cli): add restart hint after allowed-hostname change
The server builds its hostname allow-set once at startup. When users
add a new hostname via the CLI, the config file is updated but the
running server doesn't reload it. This adds a clear message telling
users to restart the server for the change to take effect.

Fixes #538

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:43:40 -07:00
Dotta
3a003e11cc Merge pull request #545 from paperclipai/feat/worktree-and-routing-polish
Add worktree:make and routing polish
2026-03-10 17:38:17 -05:00
Dotta
d388255e66 Remove inbox New tab badge count 2026-03-10 17:05:47 -05:00
Dotta
80d87d3b4e Add paperclipai worktree:make command 2026-03-10 16:52:26 -05:00
Dotta
21eb904a4d fix(server): keep pretty logger metadata on one line 2026-03-10 16:42:36 -05:00
Dotta
d62b89cadd ui: add company-aware not found handling 2026-03-10 16:38:46 -05:00
Dotta
78207304d4 Merge pull request #540 from paperclipai/nm/worktree-favicon
Add worktree-specific favicon branding
2026-03-10 16:33:20 -05:00
Dotta
c799fca313 Add worktree-specific favicon branding 2026-03-10 16:15:11 -05:00
Dotta
50db379db2 Merge pull request #536 from paperclipai/fix/dev-migration-flow
Fix dev migration prompt and embedded db:migrate
2026-03-10 15:32:10 -05:00
Dotta
56aeddfa1c Fix dev migration prompt and embedded db:migrate 2026-03-10 15:31:05 -05:00
Dotta
42c8aca5c0 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  Fix approvals service idempotency test
  Add workspace strategy plan doc
  Copy git hooks during worktree init
2026-03-10 15:09:00 -05:00
Dotta
00495d3d89 Merge pull request #534 from paperclipai/fix/approval-service-idempotency
Fix approvals service idempotency test
2026-03-10 15:07:45 -05:00
Dotta
a613435249 Fix approvals service idempotency test 2026-03-10 15:05:19 -05:00
Dotta
576b408682 Merge pull request #532 from paperclipai/feature/worktree-init-copy-hooks
Copy git hooks during worktree init
2026-03-10 14:58:18 -05:00
Dotta
193b7c0570 Add workspace strategy plan doc 2026-03-10 14:57:18 -05:00
Dotta
93a8b55ff8 Copy git hooks during worktree init 2026-03-10 14:55:35 -05:00
Dotta
24a553c255 doc for workspace strategy 2026-03-10 14:52:43 -05:00
Dotta
2332a79e0b Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  updating paths
  Rebind seeded project workspaces to the current worktree
  Add command-based worktree provisioning
  Refine project and agent configuration UI
  Add configuration tabs to project and agent pages
  Add project-first execution workspace policies
  Add worktree-aware workspace runtime support
2026-03-10 14:47:50 -05:00
Dotta
65af1d77a4 Merge pull request #530 from paperclipai/feature/workspace-runtime-support
Add issue worktree runtime support
2026-03-10 14:46:29 -05:00
Dotta
b0b7ec779a updating paths 2026-03-10 14:43:34 -05:00
Dotta
859c82aa12 Merge remote-tracking branch 'public-gh/master' into feature/workspace-runtime-support
* public-gh/master:
  Rebind seeded project workspaces to the current worktree
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
2026-03-10 14:19:11 -05:00
Dotta
6fd29e05ad Merge pull request #522 from paperclipai/feature/worktree-rebind-seeded-workspaces
Rebind seeded project workspaces to the current worktree
2026-03-10 13:54:50 -05:00
Dotta
12216b5cc6 Rebind seeded project workspaces to the current worktree 2026-03-10 13:50:29 -05:00
Dotta
0c525febf2 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  Fix doctor summary after repairs
  Fix worktree minimal clone startup
  Add minimal worktree seed mode
  Add worktree init CLI for isolated development instances
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
  Apply suggestions from code review
  fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 13:41:47 -05:00
Dotta
b0fe48b730 Revert "ui: move settings to footer icon beside theme toggle"
This reverts commit f3a9b6de21.
2026-03-10 13:41:37 -05:00
Dotta
f3a9b6de21 ui: move settings to footer icon beside theme toggle 2026-03-10 13:23:35 -05:00
Dotta
31561724f7 Merge pull request #491 from lazmo88/fix/parentid-filter-issues-list
fix: wire parentId query filter into issues list endpoint
2026-03-10 13:07:20 -05:00
Dotta
c363428966 Merge pull request #517 from paperclipai/feature/worktree-seed-secrets-key
Copy seeded secrets key into worktree instances
2026-03-10 12:59:54 -05:00
Dotta
f783f66866 Merge pull request #495 from subhendukundu/feat/configurable-attachment-types
feat: make attachment content types configurable via env var
2026-03-10 12:58:15 -05:00
Dotta
deec68ab16 Copy seeded secrets key into worktree instances 2026-03-10 12:57:53 -05:00
Dotta
6733a6cd7e Merge pull request #502 from davidahmann/codex/issue-499-approval-idempotency
Make approval resolution retries idempotent
2026-03-10 12:56:30 -05:00
Dotta
dfbb4f1ccb Add command-based worktree provisioning 2026-03-10 12:42:36 -05:00
Aaron
6956dad53a fix(adapters/gemini-local): address PR review feedback
- Update stale doc comment in index.ts to reflect direct ~/.gemini/skills/
  injection instead of tmpdir approach
- Remove bare GEMINI_API_KEY/GOOGLE_API_KEY from auth regex to prevent
  false positives when those strings appear in assistant output
- Align hello probe sandbox/approvalMode flags with execute.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:46:04 +00:00
Aaron
e9fc403b94 fix(adapters/gemini-local): inject skills into ~/.gemini/ instead of tmpdir
GEMINI_CLI_HOME pointed to a tmpdir which broke OAuth auth since the CLI
couldn't find credentials in the real home directory.

Instead, inject Paperclip skills directly into ~/.gemini/skills/ (matching
the pattern used by cursor, codex, pi, and opencode adapters). This lets
the Gemini CLI find both auth credentials and skills in their natural
location without any GEMINI_CLI_HOME override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:46:04 +00:00
Aaron
8eb8b16047 fix(adapters/gemini-local): downgrade missing API key to info level
The Gemini CLI supports OAuth login via `gemini auth login` which stores
credentials locally without setting any env vars. The previous warn-level
check on missing GEMINI_API_KEY caused false alarms when CLI-based OAuth
was used. The hello probe that follows is the real auth authority — if
auth is actually broken, it will catch it and report appropriately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:46:04 +00:00
Aaron
4e5f67ef96 feat(adapters/gemini-local): add auth detection, turn-limit handling, sandbox, and approval modes
Incorporate improvements from PR #13 and #105 into the gemini-local adapter:

- Add detectGeminiAuthRequired() for runtime auth failure detection with
  errorCode: "gemini_auth_required" on execution results
- Add isGeminiTurnLimitResult() to detect exit code 53 / turn_limit status
  and clear session to prevent stuck sessions on next heartbeat
- Add describeGeminiFailure() for structured error messages from parsed
  result events including errors array extraction
- Return parsed resultEvent in resultJson instead of raw stdout/stderr
- Add isRetry guard to prevent stale session ID fallback after retry
- Replace boolean yolo with approvalMode string (default/auto_edit/yolo)
  with backwards-compatible config.yolo fallback
- Add sandbox config option (--sandbox / --sandbox=none)
- Add GOOGLE_GENAI_USE_GCA auth detection in environment test
- Consolidate auth detection regex into shared detectGeminiAuthRequired()
- Add gemini-2.0-flash and gemini-2.0-flash-lite model IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:46:04 +00:00
Aditya Sasidhar
ec445e4cc9 fix(adapters/gemini-local): address PR review feedback for skills and formatting
- Isolate skills injection using a temporary directory mapped via
  GEMINI_CLI_HOME, mirroring the claude-local sandbox approach
  instead of polluting the global ~/.gemini/skills directory.
- Update the environment probe to use `--output-format stream-json`
  so the payload matches the downstream parseGeminiJsonl parser.
- Deduplicate `firstNonEmptyLine` helper by extracting it to a
  shared `utils.ts` module.
- Clean up orphaned internal exports and update adapter documentation.
2026-03-10 16:46:04 +00:00
Aditya Sasidhar
af97259a9c feat(adapters): add Gemini CLI local adapter support
Signed-off-by: Aditya Sasidhar <telikicherlaadityasasidhar@gmail.com>
2026-03-10 16:46:04 +00:00
David Ahmann
9c68c1b80b server: make approval retries idempotent (#499) 2026-03-10 12:00:29 -04:00
Dotta
e94ce47ba5 Refine project and agent configuration UI 2026-03-10 10:58:43 -05:00
Dotta
6186eba098 Add configuration tabs to project and agent pages 2026-03-10 10:58:43 -05:00
Dotta
b83a87f42f Add project-first execution workspace policies 2026-03-10 10:58:43 -05:00
Dotta
3120c72372 Add worktree-aware workspace runtime support 2026-03-10 10:58:38 -05:00
Dotta
7934952a77 Merge pull request #496 from paperclipai/feature/worktree-development-tools
feat(cli): add isolated worktree-local Paperclip instance tools
2026-03-10 10:56:36 -05:00
Dotta
d9574fea71 Fix doctor summary after repairs 2026-03-10 10:13:05 -05:00
Dotta
83738b45cd Fix worktree minimal clone startup 2026-03-10 10:13:05 -05:00
Dotta
4a67db6a4d Add minimal worktree seed mode 2026-03-10 10:13:05 -05:00
Dotta
0704854926 Add worktree init CLI for isolated development instances 2026-03-10 10:13:05 -05:00
Subhendu Kundu
1959badde7 fix: address review feedback — stale error message and * wildcard
- assets.ts: change "Image exceeds" to "File exceeds" in size-limit error
- attachment-types.ts: handle plain "*" as allow-all wildcard pattern
- Add test for "*" wildcard (12 tests total)
2026-03-10 20:01:08 +05:30
Subhendu Kundu
3ff07c23d2 Update server/src/routes/assets.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 19:54:42 +05:30
Subhendu Kundu
dec02225f1 feat: make attachment content types configurable via env var
Add PAPERCLIP_ALLOWED_ATTACHMENT_TYPES env var to configure allowed
MIME types for issue attachments and asset uploads. Supports exact
types (application/pdf) and wildcard patterns (image/*, text/*).

Falls back to the existing image-only defaults when the env var is
unset, preserving backward compatibility.

- Extract shared module `attachment-types.ts` with `isAllowedContentType()`
  and `matchesContentType()` (pure, testable)
- Update `routes/issues.ts` and `routes/assets.ts` to use shared module
- Add unit tests for parsing and wildcard matching

Closes #487
2026-03-10 19:40:22 +05:30
Claude
f6f5fee200 fix: wire parentId query filter into issues list endpoint
The parentId parameter on GET /api/companies/:companyId/issues was
silently ignored — the filter was never extracted from the query string,
never passed to the service layer, and the IssueFilters type did not
include it. All other filters (status, assigneeAgentId, projectId, etc.)
worked correctly.

This caused subtask lookups to return every issue in the company instead
of only children of the specified parent.

Changes:
- Add parentId to IssueFilters interface
- Add eq(issues.parentId, filters.parentId) condition in list()
- Extract parentId from req.query in the route handler

Fixes: LAS-101
2026-03-10 15:54:31 +02:00
Dotta
49b9511889 Merge pull request #485 from jknair/fix/strip-claudecode-env-from-child-processes
fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 07:25:30 -05:00
Dotta
1a53567cb6 Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 07:24:48 -05:00
Jayakrishnan
9248881d42 fix(adapter-utils): strip Claude Code env vars from child processes
When the Paperclip server is started from within a Claude Code session
(e.g. `npx paperclipai run` in a Claude Code terminal), the `CLAUDECODE`
and related env vars (`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION`,
`CLAUDE_CODE_PARENT_SESSION`) leak into `process.env`. Since
`runChildProcess()` spreads `process.env` into the child environment,
every spawned `claude` CLI process inherits these vars and immediately
exits with: "Claude Code cannot be launched inside another Claude Code
session."

This is particularly disruptive for the `claude-local` adapter, where
every agent run spawns a `claude` child process. A single contaminated
server start (or cron job that inherits the env) silently breaks all
agent executions until the server is restarted in a clean environment.

The fix deletes the four known Claude Code nesting-guard env vars from
the merged environment before passing it to `spawn()`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:01:46 +00:00
Dotta
ef978dd601 Merge pull request #446 from paperclipai/codex/pr-report-skill
feat: add pr-report skill
2026-03-09 17:05:35 -05:00
Dotta
fbf9d5714f feat: add pr-report skill 2026-03-09 17:01:45 -05:00
Dotta
8ac064499f Merge pull request #445 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:45:02 -05:00
Dotta
cbbf695c35 release files 2026-03-09 16:43:53 -05:00
Dotta
7e8908afa2 chore: release v0.3.0 2026-03-09 16:31:12 -05:00
Dotta
58d4d04e99 Merge pull request #444 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:20:22 -05:00
Dotta
c672b71f7f Refresh bootstrap gate while setup is pending 2026-03-09 16:13:15 -05:00
Dotta
01c5a6f198 Unblock canary onboard smoke bootstrap 2026-03-09 16:06:16 -05:00
adamrobbie
8a7b7a2383 docs: remove obsolete TODO for CONTRIBUTING.md 2026-03-09 16:58:57 -04:00
Dotta
64f5c3f837 Fix authenticated smoke bootstrap flow 2026-03-09 15:30:08 -05:00
Dotta
c62266aa6a tweaks to docker smoke 2026-03-09 14:41:00 -05:00
Dotta
5dd1e6335a Fix root TypeScript solution config 2026-03-09 14:09:30 -05:00
Dotta
469bfe3953 chore: add release train workflow 2026-03-09 13:55:30 -05:00
Dotta
d20341c797 Merge pull request #413 from online5880/fix/windows-command-compat
fix: support Windows command wrappers for local adapters
2026-03-09 12:50:20 -05:00
online5880
756ddb6cf7 fix: remove lockfile changes from PR 2026-03-10 02:34:52 +09:00
Dotta
200dd66f63 Merge pull request #400 from AiMagic5000/fix/docker-non-root-node-user
fix(docker): run production server as non-root node user
2026-03-09 12:18:20 -05:00
Dotta
9859bac440 Merge pull request #423 from RememberV/fix/onboarding-navigates-to-dashboard
fix: navigate to dashboard after onboarding, not first issue
2026-03-09 12:14:58 -05:00
online5880
8d6b20b47b Merge branch 'master' into fix/windows-command-compat 2026-03-10 02:05:41 +09:00
online5880
a418106005 fix: restore cross-env in server dev watch 2026-03-10 01:43:45 +09:00
Dotta
84ef17bf85 Merge pull request #424 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-09 11:43:44 -05:00
lockfile-bot
23dec980e2 chore(lockfile): refresh pnpm-lock.yaml 2026-03-09 16:41:30 +00:00
Dotta
03c37f8dea Merge pull request #427 from paperclipai/dotta-releases
Dotta releases
2026-03-09 11:41:11 -05:00
Dotta
8360b2e3e3 fix: complete authenticated onboarding startup 2026-03-09 11:26:58 -05:00
Dotta
d9ba4790e9 Merge branch 'master' into fix/windows-command-compat 2026-03-09 11:25:18 -05:00
Dotta
3ec96fdb73 fix: complete authenticated docker onboard smoke 2026-03-09 11:12:34 -05:00
Dotta
eecb780dd7 Merge pull request #420 from paperclipai/dotta-releases
Dotta releases
2026-03-09 11:04:16 -05:00
Dotta
632079ae3b chore: require frozen lockfile for releases 2026-03-09 10:43:04 -05:00
Dotta
7d8d6a5caf chore: remove lockfile changes from release branch 2026-03-09 10:38:18 -05:00
Dotta
948080fee9 Revert "chore: restore pnpm-lock.yaml from master"
This reverts commit 8d53800c19.
2026-03-09 10:37:38 -05:00
RememberV
af0e05f38c fix: onboarding wizard navigates to dashboard instead of first issue
After onboarding, the wizard navigated to the newly created issue
(e.g. /JAR/issues/JAR-1). useCompanyPageMemory then saved this path,
causing every subsequent company switch to land on that stale issue
instead of the dashboard.

Remove the issue-specific navigation branch so handleLaunch always
falls through to the dashboard route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:35:40 +00:00
Dotta
8d53800c19 chore: restore pnpm-lock.yaml from master 2026-03-09 10:35:32 -05:00
Dotta
422f57b160 chore: use public-gh for manual release flow 2026-03-09 10:33:56 -05:00
Dotta
31c947bf7f fix: publish canaries in changesets pre mode 2026-03-09 10:23:04 -05:00
Dotta
f5bf743745 fix: support older git in release cleanup 2026-03-09 10:11:46 -05:00
Dotta
0a8b96cdb3 http clone 2026-03-09 10:03:45 -05:00
Dotta
a47ea343ba feat: add committed-ref onboarding smoke script 2026-03-09 09:59:43 -05:00
Dotta
0781b7a15c v0.3.0.md release changelog 2026-03-09 09:53:35 -05:00
Dotta
30ee59c324 chore: simplify release preflight workflow 2026-03-09 09:37:18 -05:00
Dotta
aa2b11d528 feat: extend release preflight smoke options 2026-03-09 09:21:56 -05:00
Dotta
e1ddcbb71f fix: disable git pagers in release preflight 2026-03-09 09:13:49 -05:00
Dotta
df94c98494 chore: add release preflight workflow 2026-03-09 09:06:45 -05:00
Dotta
a7cfd9f24b chore: formalize release workflow 2026-03-09 08:49:42 -05:00
Dotta
e48beafc90 Merge pull request #415 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-09 08:27:43 -05:00
lockfile-bot
e6e41dba9d chore(lockfile): refresh pnpm-lock.yaml 2026-03-09 13:27:18 +00:00
online5880
f4a9788f2d fix: tighten Windows adapter command handling 2026-03-09 22:08:50 +09:00
Dotta
ccd501ea02 feat: add Playwright e2e tests for onboarding wizard flow
Scaffolds end-to-end testing with Playwright for the onboarding wizard.
Runs in skip_llm mode by default (UI-only, no LLM costs). Set
PAPERCLIP_E2E_SKIP_LLM=false for full heartbeat verification.

- tests/e2e/playwright.config.ts: Playwright config with webServer
- tests/e2e/onboarding.spec.ts: 4-step wizard flow test
- .github/workflows/e2e.yml: manual workflow_dispatch CI workflow
- package.json: test:e2e and test:e2e:headed scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:00:08 -05:00
online5880
d7b98a72b4 fix: support Windows command wrappers for local adapters 2026-03-09 21:52:06 +09:00
Dotta
210715117c Merge pull request #412 from paperclipai/dotta
Dotta
2026-03-09 07:50:18 -05:00
Dotta
38cb2bf3c4 fix: add missing disableSignUp to auth config objects in CLI
All auth config literals in the CLI were missing the required
disableSignUp field after it was added to authConfigSchema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:43:47 -05:00
Dotta
f2a0a0b804 fix: restore force push in lockfile refresh workflow
Simplify the PR-based flow: force push to update the branch if it
already exists, and only create a new PR when one doesn't exist yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:38:49 -05:00
Dotta
035e1a9333 fix: use lockfile-bot identity and remove force push in refresh workflow
- Use lockfile-bot name/email instead of github-actions[bot]
- Remove force push: close any stale PR and delete branch first,
  then create a fresh branch and PR each time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:33:49 -05:00
Dotta
ec4667c8b2 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  fix: disable secure cookies for HTTP deployments
  feat(adapters): add claude-sonnet-4-6 and claude-haiku-4-6 models
  Add opencode-ai to global npm install in Dockerfile
  fix: correct env var priority for authDisableSignUp
  Add pi-local package.json to Dockerfile
  feat: add auth.disableSignUp config option
  refactor: extract roleLabels to shared constants
  fix(secrets): add secretKeys tracking to resolveEnvBindings for consistent redaction
  fix(db): reuse MIGRATIONS_FOLDER constant instead of recomputing
  fix(server): wake agent when issue status changes from backlog
  fix(server): use home-based path for run logs instead of cwd
  fix(db): use fileURLToPath for Windows-safe migration paths
  fix(server): auto-deduplicate agent names on creation instead of rejecting
  feat(ui): show human-readable role labels in agent list and properties
  fix(ui): prevent blank screen when prompt template is emptied
  fix(server): redact secret-sourced env vars in run logs by provenance
  fix(cli): split path and query in buildUrl to prevent %3F encoding
  fix(scripts): use shell on Windows to fix spawn EINVAL in dev-runner
2026-03-09 07:29:34 -05:00
Dotta
f32b76f213 fix: replace third-party action with gh CLI for lockfile PR creation
Replace peter-evans/create-pull-request with plain gh CLI commands to
avoid third-party supply chain risk. Uses only GitHub's own tooling
(GITHUB_TOKEN + gh CLI) to create the lockfile refresh PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:26:31 -05:00
Dotta
ee7fddf8d5 fix: convert lockfile refresh to PR-based flow for protected master
The refresh-lockfile workflow was pushing directly to master, which fails
with branch protection rules. Convert to use peter-evans/create-pull-request
to create a PR instead. Exempt the bot's branch from the lockfile policy check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:22:34 -05:00
Dotta
77e04407b9 fix(publish): always bundle ui-dist into server package 2026-03-09 07:21:33 -05:00
Daniil Okhlopkov
1a75e6d15c fix: default dangerouslySkipPermissions to true for unattended agents
Agents run unattended and cannot respond to interactive permission
prompts from Claude Code. When dangerouslySkipPermissions is false
(the previous default), Claude Code blocks file operations with
"Claude requested permissions to write to /path, but you haven't
granted it yet" — making agents unable to edit files.

The OnboardingWizard already sets this to true for claude_local
agents (OnboardingWizard.tsx:277), but agents created or edited
outside the wizard inherit the default of false, breaking them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:56:40 +09:00
Dominic O'Carroll
5e18ccace7 fix: route heartbeat cost recording through costService
Heartbeat runs recorded costs via direct SQL inserts into costEvents and
agents.spentMonthlyCents, bypassing costService.createEvent(). This skipped:
- companies.spentMonthlyCents update (company budget never incremented)
- Agent auto-pause when budget exceeded (enforcement gap)

Now calls costService(db).createEvent() which handles all three:
insert cost event, update agent spend, update company spend, and
auto-pause agent when budgetMonthlyCents is exceeded.
2026-03-09 14:49:01 +10:00
Dotta
7b70713fcb Merge pull request #376 from dalestubblefield/fix/http-secure-cookies
fix: disable secure cookies for HTTP deployments
2026-03-08 22:15:54 -05:00
Dale Stubblefield
ad55af04cc fix: disable secure cookies for HTTP deployments
Fixes login failing silently on authenticated + private deployments
served over plain HTTP (e.g. Tailscale, LAN). Users can sign up and
sign in, but the session cookie is rejected by the browser so they
are immediately redirected back to the login page.

Better Auth defaults to __Secure- prefixed cookies with the Secure
flag when NODE_ENV=production. Browsers silently reject Secure cookies
on non-HTTPS origins. This detects when PAPERCLIP_PUBLIC_URL uses
http:// and sets useSecureCookies: false so session cookies work
without HTTPS.
2026-03-08 22:00:51 -05:00
AiMagic5000
57406dbc90 fix(docker): run production server as non-root node user
Switch the production stage to the built-in node user from
node:lts-trixie-slim, fixing two runtime failures:

1. Claude CLI rejects --dangerously-skip-permissions when the
   process UID is 0, making the claude-local adapter unusable.
2. The server crashed at startup (EACCES) because /paperclip was
   root-owned and the process could not write logs or instance data.

Changes vs the naive fix:
- Use COPY --chown=node:node instead of a separate RUN chown -R,
  avoiding a duplicate image layer that would double the size of
  the /app tree in the final image.
- Consolidate mkdir /paperclip + chown into the same RUN layer as
  the npm global install (already runs as root) to keep layer count
  minimal.
- Add USER node before CMD so the process runs unprivileged.

The VOLUME declaration comes after chown so freshly-mounted
anonymous volumes inherit the correct node:node ownership.

Fixes #344
2026-03-08 13:47:59 -07:00
Dotta
e35e2c4343 Fine-tune mobile status icon alignment on issues page: add 1px top padding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:00:02 -05:00
Dotta
d58f269281 Fix mobile status icon vertical alignment: remove pt-0.5 to center with text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:58:39 -05:00
Dotta
2a7043d677 GitHub-style mobile issue rows: status left column, hide priority, unread dot right
- Move status icon to left column on mobile across issues list, inbox, and dashboard
- Hide priority icon on mobile (only show on desktop)
- Move unread indicator dot to right side vertically centered on mobile inbox
- Stale work section: show status icon instead of clock on mobile
- Desktop layout unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:56:17 -05:00
Dotta
31b5ff1c61 Add bottom padding to new issue description editor for iOS keyboard
The description text was being covered by the property chips bar when
typing long content on iOS with the virtual keyboard open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 08:21:07 -05:00
Dotta
c674462a02 Merge pull request #238 from fahmmin/fix/dev-runner-windows-spawn
fix(scripts): use shell on Windows to fix spawn EINVAL in dev-runner
2026-03-07 21:47:47 -06:00
Dotta
e3ff0c8e1b Merge pull request #279 from JasonOA888/feat/issue-241-disable-signup
feat: add auth.disableSignUp config option
2026-03-07 21:30:16 -06:00
Dotta
17b10c43fe Merge pull request #260 from mvanhorn/fix/204-heartbeat-url-encoding
fix(cli): split path and query in buildUrl to prevent %3F encoding
2026-03-07 21:23:05 -06:00
Dotta
343d4e5877 Merge pull request #261 from mvanhorn/fix/234-secret-env-redaction
fix(server): redact secret-sourced env vars in logs by provenance
2026-03-07 21:22:00 -06:00
Dotta
1078c7dd2b Merge pull request #262 from mvanhorn/fix/191-blank-screen-prompt-template
fix(ui): prevent blank screen when prompt template is emptied
2026-03-07 21:20:46 -06:00
Dotta
4c630bc66e Merge pull request #263 from mvanhorn/feat/180-agent-role-labels
feat(ui): show human-readable role labels in agent list and properties
2026-03-07 21:19:58 -06:00
Dotta
f5190f28d1 Merge pull request #264 from mvanhorn/fix/232-deduplicate-agent-names
fix(server): auto-deduplicate agent names on creation
2026-03-07 21:18:31 -06:00
Dotta
edfc6be63c Merge pull request #265 from mvanhorn/fix/132-windows-path-doubling
fix(db): use fileURLToPath for Windows-safe migration paths
2026-03-07 21:16:58 -06:00
Dotta
61551ffea3 Merge pull request #266 from mvanhorn/fix/89-run-log-location
fix(server): use home-based path for run logs instead of cwd
2026-03-07 21:15:56 -06:00
Dotta
0fedd8a395 Merge pull request #267 from mvanhorn/fix/167-backlog-assignee-wake
fix(server): wake agent when issue status changes from backlog
2026-03-07 21:14:52 -06:00
Dotta
b090c33ca1 Merge pull request #283 from mingfang/patch-1
Add pi-local package.json to Dockerfile
2026-03-07 21:07:07 -06:00
Dotta
3fb96506bd Merge pull request #284 from mingfang/patch-2
Add opencode-ai to global npm install in Dockerfile
2026-03-07 21:06:09 -06:00
Dotta
dcf879f6fb Merge pull request #293 from cpfarhood/feat/add-claude-4-6-models
feat(adapters): add claude-sonnet-4-6 and claude-haiku-4-6 to claude_local model list
2026-03-07 21:00:07 -06:00
Chris Farhood
4e01633202 feat(adapters): add claude-sonnet-4-6 and claude-haiku-4-6 models 2026-03-07 21:57:06 -05:00
Ming Fang
ff3f04ff48 Add opencode-ai to global npm install in Dockerfile 2026-03-07 21:24:56 -05:00
Jason
91fda5d04f fix: correct env var priority for authDisableSignUp
- Env var now properly overrides file config in both directions
- Follows established pattern for boolean config flags
- Removed redundant ?? false (field is typed boolean)
- PAPERCLIP_AUTH_DISABLE_SIGN_UP can now set to 'false' to
  override file config's 'true'
2026-03-08 10:18:27 +08:00
Ming Fang
77e06c57f9 Add pi-local package.json to Dockerfile 2026-03-07 21:15:12 -05:00
Dotta
0f75c35392 Fix unread dot making inbox rows taller than read rows
Replace <button> with <span role="button"> for the unread dot to
eliminate browser UA button styles (min-height, padding) that caused
unread rows to be taller despite explicit h-4 constraint. Also
reduce dot from h-2.5/w-2.5 to h-2/w-2 for a more subtle indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:12:26 -06:00
Dotta
45473b3e72 Move scroll-to-bottom button to issue detail and run pages
Removed the scroll-to-bottom button from IssuesList (wrong location)
and created a shared ScrollToBottom component. Added it to IssueDetail
and RunDetail pages. On mobile, the button sits above the bottom nav
to avoid overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:07:39 -06:00
Dotta
a96556b8f4 Move unread badge inline with metadata row in inbox
Moves the unread dot from a separate left column into the metadata
line (alongside status/identifier), with an empty placeholder for
read items to keep spacing consistent. Reduces left padding on
mobile inbox rows to reclaim space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:04:41 -06:00
Dotta
ce8fe38ffc Unify mobile issue row layout across issues, inbox, and dashboard
Add PriorityIcon and timeAgo to IssuesList mobile rows to match the
pattern used in Inbox and Dashboard. Align Dashboard row padding to
match Inbox. All mobile issue rows now show: title (2-line clamp),
then priority + status + identifier + relative time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:04:32 -06:00
Dotta
6e86f69f95 Unify issue rows with GitHub-style mobile layout across app
On mobile, all issue rows now show title first (up to 2 lines),
with metadata (icons, identifier, timestamp) on a second line below.
Desktop layout is preserved as single-line rows.

Affected locations: Inbox recent issues, Inbox stale work,
Dashboard recent tasks, and IssuesList.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:01:16 -06:00
Dotta
7661fae4b3 Fix inbox row irregular heights on mobile from unread badge
- Give unread dot container fixed h-5 so rows are consistent height
  regardless of badge presence
- Use flex-wrap on mobile so title gets its own line with line-clamp-2
- On sm+ screens, keep single-line truncated layout
- Move timestamp to ml-auto with sm:order-last for clean wrapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:55:32 -06:00
Dotta
ba080cb4dd Fix stale work section overflowing on mobile in inbox
Add min-w-0 and overflow-hidden to the stale work row flex containers
so the title truncates properly on narrow screens. Add shrink-0 to
identifier and assignee spans to prevent them from being compressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:52:29 -06:00
Jason
3860812323 feat: add auth.disableSignUp config option
- Added disableSignUp to authConfigSchema in config-schema.ts
- Added authDisableSignUp to Config interface
- Added parsing from PAPERCLIP_AUTH_DISABLE_SIGN_UP env or config file
- Passed to better-auth emailAndPassword.disableSignUp

When true, blocks new user registrations on public instances.
Defaults to false (backward compatible).

Fixes #241
2026-03-08 09:38:32 +08:00
Matt Van Horn
2639184f46 refactor: extract roleLabels to shared constants
Move duplicated roleLabels map from AgentProperties.tsx, Agents.tsx,
OrgChart.tsx, and agent-config-primitives.tsx into AGENT_ROLE_LABELS
in packages/shared/src/constants.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:07:14 -08:00
Matt Van Horn
61966fba1f fix(secrets): add secretKeys tracking to resolveEnvBindings for consistent redaction
resolveEnvBindings now returns { env, secretKeys } matching the pattern
already used by resolveAdapterConfigForRuntime, so any caller can redact
secret-sourced values by provenance rather than key-name heuristics alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:06:10 -08:00
Matt Van Horn
54b512f9e0 fix(db): reuse MIGRATIONS_FOLDER constant instead of recomputing
The local migrationsFolder variable in migratePostgresIfEmpty duplicated
the module-level MIGRATIONS_FOLDER constant. Reuse the constant to keep
a single source of truth for the migration path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:01:36 -08:00
Dotta
667d23e79e Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Revert lockfile changes from OpenClaw gateway cleanup
  Enhance plugin architecture by introducing agent tool contributions and plugin-to-plugin communication. Update workspace plugin definitions for direct OS access and refine UI extension surfaces. Document new capabilities for plugin settings UI and lifecycle management.
  Remove legacy OpenClaw adapter and keep gateway-only flow
  Fix CI typecheck and default OpenClaw sessions to issue scope
  fix(server): serve cached index.html in SPA catch-all to prevent 500
  Add CEO OpenClaw invite endpoint and update onboarding UX
  openclaw gateway: auto-approve first pairing and retry
  openclaw gateway: persist device keys on create/update and clarify pairing flow
  plugin spec draft ideas v0
  openclaw gateway: persist device keys and smoke pairing flow
  openclaw-gateway: document and surface pairing-mode requirements
  fix(smoke): disambiguate case C ack comment target
  fix(openclaw-gateway): enforce join token validation and add smoke preflight gates
  fix(openclaw): make invite snippet/onboarding gateway-first
2026-03-07 18:59:23 -06:00
Matt Van Horn
20b171bd16 fix(server): wake agent when issue status changes from backlog
Previously, agents were only woken when the assignee changed. Now
also wakes the assigned agent when an issue transitions out of
backlog status (e.g. backlog -> todo).

Fixes #167

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:18:41 -08:00
Matt Van Horn
3f2274cd8d fix(server): use home-based path for run logs instead of cwd
Run logs defaulted to process.cwd()/data/run-logs, placing logs in
unexpected locations when launched from non-home directories. Now
defaults to ~/.paperclip/instances/<id>/data/run-logs/.

Fixes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:18:14 -08:00
Matt Van Horn
c59e059976 fix(db): use fileURLToPath for Windows-safe migration paths
URL.pathname returns /C:/... on Windows, causing doubled drive letters
when Node prepends the current drive. fileURLToPath handles this
correctly across platforms.

Fixes #132

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:17:45 -08:00
Matt Van Horn
9933039094 fix(server): auto-deduplicate agent names on creation instead of rejecting
Replace assertCompanyShortnameAvailable with deduplicateAgentName in
the create path so duplicate names get auto-suffixed (e.g. Engineer 2)
instead of throwing a conflict error.

Fixes #232

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:08:53 -08:00
Matt Van Horn
b886eb3cf0 feat(ui): show human-readable role labels in agent list and properties
Use roleLabels lookup in list view subtitle and AgentProperties
panel instead of raw role strings.

Fixes #180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:08:52 -08:00
Matt Van Horn
53c944e8bc fix(ui): prevent blank screen when prompt template is emptied
Change onChange handler from v || undefined to v ?? "" so empty
strings don't become undefined and crash downstream .trim() calls.

Fixes #191

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:08:51 -08:00
Matt Van Horn
977f5570be fix(server): redact secret-sourced env vars in run logs by provenance
resolveAdapterConfigForRuntime now returns a secretKeys set tracking
which env vars came from secret_ref bindings. The onAdapterMeta
callback uses this to redact them regardless of key name.

Fixes #234

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:08:49 -08:00
Matt Van Horn
609b55f530 fix(cli): split path and query in buildUrl to prevent %3F encoding
The URL constructor's pathname setter encodes ? as %3F, breaking
heartbeat event polling. Split query params before assignment.

Fixes #204

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:08:48 -08:00
Fahmin
346152f67d fix(scripts): use shell on Windows to fix spawn EINVAL in dev-runner 2026-03-08 00:15:56 +05:30
Chris Schneider
f99f174e2d Show issue creator in properties sidebar 2026-03-06 17:16:39 +00:00
311 changed files with 37302 additions and 4881 deletions

View File

@@ -0,0 +1,202 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
---
# PR Report Skill
Produce a maintainer-grade review of a PR, branch, or large contribution.
Default posture:
- understand the change before judging it
- explain the system as built, not just the diff
- separate architectural problems from product-scope objections
- make a concrete recommendation, not a vague impression
## When to Use
Use this skill when the user asks for things like:
- "review this PR deeply"
- "explain this contribution to me"
- "make me a report or webpage for this PR"
- "compare this design to similar systems"
- "should I merge this?"
## Outputs
Common outputs:
- standalone HTML report in `tmp/reports/...`
- Markdown report in `report/` or another requested folder
- short maintainer summary in chat
If the user asks for a webpage, build a polished standalone HTML artifact with
clear sections and readable visual hierarchy.
Resources bundled with this skill:
- `references/style-guide.md` for visual direction and report presentation rules
- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter
## Workflow
### 1. Acquire and frame the target
Work from local code when possible, not just the GitHub PR page.
Gather:
- target branch or worktree
- diff size and changed subsystems
- relevant repo docs, specs, and invariants
- contributor intent if it is documented in PR text or design docs
Start by answering: what is this change *trying* to become?
### 2. Build a mental model of the system
Do not stop at file-by-file notes. Reconstruct the design:
- what new runtime or contract exists
- which layers changed: db, shared types, server, UI, CLI, docs
- lifecycle: install, startup, execution, UI, failure, disablement
- trust boundary: what code runs where, under what authority
For large contributions, include a tutorial-style section that teaches the
system from first principles.
### 3. Review like a maintainer
Findings come first. Order by severity.
Prioritize:
- behavioral regressions
- trust or security gaps
- misleading abstractions
- lifecycle and operational risks
- coupling that will be hard to unwind
- missing tests or unverifiable claims
Always cite concrete file references when possible.
### 4. Distinguish the objection type
Be explicit about whether a concern is:
- product direction
- architecture
- implementation quality
- rollout strategy
- documentation honesty
Do not hide an architectural objection inside a scope objection.
### 5. Compare to external precedents when needed
If the contribution introduces a framework or platform concept, compare it to
similar open-source systems.
When comparing:
- prefer official docs or source
- focus on extension boundaries, context passing, trust model, and UI ownership
- extract lessons, not just similarities
Good comparison questions:
- Who owns lifecycle?
- Who owns UI composition?
- Is context explicit or ambient?
- Are plugins trusted code or sandboxed code?
- Are extension points named and typed?
### 6. Make the recommendation actionable
Do not stop at "merge" or "do not merge."
Choose one:
- merge as-is
- merge after specific redesign
- salvage specific pieces
- keep as design research
If rejecting or narrowing, say what should be kept.
Useful recommendation buckets:
- keep the protocol/type model
- redesign the UI boundary
- narrow the initial surface area
- defer third-party execution
- ship a host-owned extension-point model first
### 7. Build the artifact
Suggested report structure:
1. Executive summary
2. What the PR actually adds
3. Tutorial: how the system works
4. Strengths
5. Main findings
6. Comparisons
7. Recommendation
For HTML reports:
- use intentional typography and color
- make navigation easy for long reports
- favor strong section headings and small reference labels
- avoid generic dashboard styling
Before building from scratch, read `references/style-guide.md`.
If a fast polished starter is helpful, begin from `assets/html-report-starter.html`
and replace the placeholder content with the actual report.
### 8. Verify before handoff
Check:
- artifact path exists
- findings still match the actual code
- any requested forbidden strings are absent from generated output
- if tests were not run, say so explicitly
## Review Heuristics
### Plugin and platform work
Watch closely for:
- docs claiming sandboxing while runtime executes trusted host processes
- module-global state used to smuggle React context
- hidden dependence on render order
- plugins reaching into host internals instead of using explicit APIs
- "capabilities" that are really policy labels on top of fully trusted code
### Good signs
- typed contracts shared across layers
- explicit extension points
- host-owned lifecycle
- honest trust model
- narrow first rollout with room to grow
## Final Response
In chat, summarize:
- where the report is
- your overall call
- the top one or two reasons
- whether verification or tests were skipped
Keep the chat summary shorter than the report itself.

View File

@@ -0,0 +1,426 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR Report Starter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #f4efe5;
--paper: rgba(255, 251, 244, 0.88);
--paper-strong: #fffaf1;
--ink: #1f1b17;
--muted: #6a6257;
--line: rgba(31, 27, 23, 0.12);
--accent: #9c4729;
--accent-soft: rgba(156, 71, 41, 0.1);
--good: #2f6a42;
--warn: #946200;
--bad: #8c2f25;
--shadow: 0 22px 60px rgba(52, 37, 19, 0.1);
--radius: 20px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
background:
radial-gradient(circle at top left, rgba(156, 71, 41, 0.12), transparent 34rem),
radial-gradient(circle at top right, rgba(47, 106, 66, 0.08), transparent 28rem),
linear-gradient(180deg, #efe6d6 0%, var(--bg) 48%, #ece5d8 100%);
}
.shell {
width: min(1360px, calc(100vw - 32px));
margin: 24px auto;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
}
.panel {
background: var(--paper);
backdrop-filter: blur(12px);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.nav {
position: sticky;
top: 20px;
align-self: start;
padding: 22px;
}
.eyebrow {
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.nav h1,
.hero h1,
h2,
h3 {
font-family: "Newsreader", serif;
line-height: 0.96;
margin: 0;
}
.nav h1 {
font-size: 2rem;
margin-top: 10px;
}
.nav p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
.nav ul {
list-style: none;
padding: 0;
margin: 18px 0 0;
display: grid;
gap: 10px;
}
.nav a {
display: block;
color: var(--ink);
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.35);
}
.nav a:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.75);
}
.meta-block {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
main {
display: grid;
gap: 24px;
}
section {
padding: 26px 28px 28px;
}
.hero {
padding: 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -3rem -6rem auto;
width: 18rem;
height: 18rem;
border-radius: 50%;
background: radial-gradient(circle, rgba(156, 71, 41, 0.14), transparent 68%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(2.6rem, 5vw, 4.6rem);
max-width: 12ch;
margin-top: 12px;
}
.lede {
margin-top: 16px;
max-width: 70ch;
font-size: 1.05rem;
line-height: 1.65;
color: #2b2723;
}
.hero-grid,
.card-grid,
.two-col {
display: grid;
gap: 14px;
}
.hero-grid {
margin-top: 24px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric,
.card,
.finding {
padding: 18px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--line);
border-radius: 18px;
}
.metric .label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric .value {
margin-top: 8px;
font-size: 1.45rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
margin-bottom: 16px;
}
h3 {
font-size: 1.3rem;
margin-bottom: 10px;
}
p {
margin: 0 0 14px;
line-height: 1.65;
}
ul,
ol {
margin: 0;
padding-left: 20px;
line-height: 1.65;
}
li + li {
margin-top: 8px;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.68);
}
.badge.good {
color: var(--good);
}
.badge.warn {
color: var(--warn);
}
.badge.bad {
color: var(--bad);
}
.quote {
margin-top: 18px;
padding: 18px;
border-left: 4px solid var(--accent);
border-radius: 14px;
background: var(--accent-soft);
}
.severity {
display: inline-flex;
margin-bottom: 12px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.severity.high {
background: rgba(140, 47, 37, 0.12);
color: var(--bad);
}
.severity.medium {
background: rgba(148, 98, 0, 0.12);
color: var(--warn);
}
.severity.low {
background: rgba(47, 106, 66, 0.12);
color: var(--good);
}
.ref {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.nav {
position: static;
}
.hero-grid,
.card-grid,
.two-col {
grid-template-columns: 1fr;
}
.hero h1 {
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel nav">
<div class="eyebrow">Maintainer Report</div>
<h1>Report Title</h1>
<p>Replace this with a concise description of what the report covers.</p>
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#tutorial">Tutorial</a></li>
<li><a href="#findings">Findings</a></li>
<li><a href="#recommendation">Recommendation</a></li>
</ul>
<div class="meta-block">
Replace with project metadata, review date, or scope notes.
</div>
</aside>
<main>
<section class="panel hero" id="summary">
<div class="eyebrow">Executive Summary</div>
<h1>Use the hero for the clearest one-line judgment.</h1>
<p class="lede">
Replace this with the short explanation of what the contribution does, why it matters,
and what the core maintainer question is.
</p>
<div class="badge-row">
<span class="badge good">Strength</span>
<span class="badge warn">Tradeoff</span>
<span class="badge bad">Risk</span>
</div>
<div class="hero-grid">
<div class="metric">
<div class="label">Overall Call</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Main Concern</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Best Part</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Weakest Part</div>
<div class="value">Placeholder</div>
</div>
</div>
<div class="quote">
Use this block for the thesis, a sharp takeaway, or a key cited point.
</div>
</section>
<section class="panel" id="tutorial">
<h2>Tutorial Section</h2>
<div class="two-col">
<div class="card">
<h3>Concept Card</h3>
<p>Use cards for mental models, subsystems, or comparison slices.</p>
<div class="ref">path/to/file.ts:10</div>
</div>
<div class="card">
<h3>Second Card</h3>
<p>Keep cards fairly dense. This template is about style, not fixed structure.</p>
<div class="ref">path/to/file.ts:20</div>
</div>
</div>
</section>
<section class="panel" id="findings">
<h2>Findings</h2>
<article class="finding">
<div class="severity high">High</div>
<h3>Finding Title</h3>
<p>Use findings for the sharpest judgment calls and risks.</p>
<div class="ref">path/to/file.ts:30</div>
</article>
</section>
<section class="panel" id="recommendation">
<h2>Recommendation</h2>
<div class="card-grid">
<div class="card">
<h3>Path Forward</h3>
<p>Use this area for merge guidance, salvage plan, or rollout advice.</p>
</div>
<div class="card">
<h3>What To Keep</h3>
<p>Call out the parts worth preserving even if the whole proposal should not land.</p>
</div>
</div>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,149 @@
# PR Report Style Guide
Use this guide when the user wants a report artifact, especially a webpage.
## Goal
Make the report feel like an editorial review, not an internal admin dashboard.
The page should make a long technical argument easy to scan without looking
generic or overdesigned.
## Visual Direction
Preferred tone:
- editorial
- warm
- serious
- high-contrast
- handcrafted, not corporate SaaS
Avoid:
- default app-shell layouts
- purple gradients on white
- generic card dashboards
- cramped pages with weak hierarchy
- novelty fonts that hurt readability
## Typography
Recommended pattern:
- one expressive serif or display face for major headings
- one sturdy sans-serif for body copy and UI labels
Good combinations:
- Newsreader + IBM Plex Sans
- Source Serif 4 + Instrument Sans
- Fraunces + Public Sans
- Libre Baskerville + Work Sans
Rules:
- headings should feel deliberate and large
- body copy should stay comfortable for long reading
- reference labels and badges should use smaller dense sans text
## Layout
Recommended structure:
- a sticky side or top navigation for long reports
- one strong hero summary at the top
- panel or paper-like sections for each major topic
- multi-column card grids for comparisons and strengths
- single-column body text for findings and recommendations
Use generous spacing. Long-form technical reports need breathing room.
## Color
Prefer muted paper-like backgrounds with one warm accent and one cool counterweight.
Suggested token categories:
- `--bg`
- `--paper`
- `--ink`
- `--muted`
- `--line`
- `--accent`
- `--good`
- `--warn`
- `--bad`
The accent should highlight navigation, badges, and important labels. Do not
let accent colors dominate body text.
## Useful UI Elements
Include small reusable styles for:
- summary metrics
- badges
- quotes or callouts
- finding cards
- severity labels
- reference labels
- comparison cards
- responsive two-column sections
## Motion
Keep motion restrained.
Good:
- soft fade/slide-in on first load
- hover response on nav items or cards
Bad:
- constant animation
- floating blobs
- decorative motion with no reading benefit
## Content Presentation
Even when the user wants design polish, clarity stays primary.
Good structure for long reports:
1. executive summary
2. what changed
3. tutorial explanation
4. strengths
5. findings
6. comparisons
7. recommendation
The exact headings can change. The important thing is to separate explanation
from judgment.
## References
Reference labels should be visually quiet but easy to spot.
Good pattern:
- small muted text
- monospace or compact sans
- keep them close to the paragraph they support
## Starter Usage
If you need a fast polished base, start from:
- `assets/html-report-starter.html`
Customize:
- fonts
- color tokens
- hero copy
- section ordering
- card density
Do not preserve the placeholder sections if they do not fit the actual report.

View File

@@ -0,0 +1,178 @@
---
name: release-changelog
description: >
Generate the stable Paperclip release changelog at releases/v{version}.md by
reading commits, changesets, and merged PR context since the last stable tag.
---
# Release Changelog Skill
Generate the user-facing changelog for the **stable** Paperclip release.
Output:
- `releases/v{version}.md`
Important rule:
- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md`
## Step 0 — Idempotency Check
Before generating anything, check whether the file already exists:
```bash
ls releases/v{version}.md 2>/dev/null
```
If it exists:
1. read it first
2. present it to the reviewer
3. ask whether to keep it, regenerate it, or update specific sections
4. never overwrite it silently
## Step 1 — Determine the Stable Range
Find the last stable tag:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log v{last}..HEAD --oneline --no-merges
```
The planned stable version comes from one of:
- an explicit maintainer request
- the chosen bump type applied to the last stable tag
- the release plan already agreed in `doc/RELEASING.md`
Do not derive the changelog version from a canary tag or prerelease suffix.
## Step 2 — Gather the Raw Inputs
Collect release data from:
1. git commits since the last stable tag
2. `.changeset/*.md` files
3. merged PRs via `gh` when available
Useful commands:
```bash
git log v{last}..HEAD --oneline --no-merges
git log v{last}..HEAD --format="%H %s" --no-merges
ls .changeset/*.md | grep -v README.md
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
```
## Step 3 — Detect Breaking Changes
Look for:
- destructive migrations
- removed or changed API fields/endpoints
- renamed or removed config keys
- `major` changesets
- `BREAKING:` or `BREAKING CHANGE:` commit signals
Key commands:
```bash
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
git diff v{last}..HEAD -- packages/db/src/schema/
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
If the requested bump is lower than the minimum required bump, flag that before the release proceeds.
## Step 4 — Categorize for Users
Use these stable changelog sections:
- `Breaking Changes`
- `Highlights`
- `Improvements`
- `Fixes`
- `Upgrade Guide` when needed
Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users.
Guidelines:
- group related commits into one user-facing entry
- write from the user perspective
- keep highlights short and concrete
- spell out upgrade actions for breaking changes
### Inline PR and contributor attribution
When a bullet item clearly maps to a merged pull request, add inline attribution at the
end of the entry in this format:
```
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
```
Rules:
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
- If you cannot determine the PR number or contributor with confidence, omit the attribution
parenthetical — do not guess.
- Core maintainer commits that don't have an external PR can omit the parenthetical.
## Step 5 — Write the File
Template:
```markdown
# v{version}
> Released: {YYYY-MM-DD}
## Breaking Changes
## Highlights
## Improvements
## Fixes
## Upgrade Guide
## Contributors
Thank you to everyone who contributed to this release!
@username1, @username2, @username3
```
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
The `Contributors` section should always be included. List every person who authored
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
in alphabetical order by GitHub username (case-insensitive).
## Step 6 — Review Before Release
Before handing it off:
1. confirm the heading is the stable version only
2. confirm there is no `-canary` language in the title or filename
3. confirm any breaking changes have an upgrade path
4. present the draft for human sign-off
This skill never publishes anything. It only prepares the stable changelog artifact.

View File

@@ -0,0 +1,261 @@
---
name: release
description: >
Coordinate a full Paperclip release across engineering verification, npm,
GitHub, website publishing, and announcement follow-up. Use when leadership
asks to ship a release, not merely to discuss version bumps.
---
# Release Coordination Skill
Run the full Paperclip release as a maintainer workflow, not just an npm publish.
This skill coordinates:
- stable changelog drafting via `release-changelog`
- release-train setup via `scripts/release-start.sh`
- prerelease canary publishing via `scripts/release.sh --canary`
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
- stable publishing via `scripts/release.sh`
- pushing the stable branch commit and tag
- GitHub Release creation via `scripts/create-github-release.sh`
- website / announcement follow-up tasks
## Trigger
Use this skill when leadership asks for:
- "do a release"
- "ship the next patch/minor/major"
- "release vX.Y.Z"
## Preconditions
Before proceeding, verify all of the following:
1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
2. The repo working tree is clean, including untracked files.
3. There are commits since the last stable tag.
4. The release SHA has passed the verification gate or is about to.
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut.
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
If any precondition fails, stop and report the blocker.
## Inputs
Collect these inputs up front:
- requested bump: `patch`, `minor`, or `major`
- whether this run is a dry run or live release
- whether the release is being run locally or from GitHub Actions
- release issue / company context for website and announcement follow-up
## Step 0 — Release Model
Paperclip now uses this release model:
1. Start or resume `release/X.Y.Z`
2. Draft the **stable** changelog as `releases/vX.Y.Z.md`
3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
4. Smoke test the canary via Docker
5. Publish the stable version `X.Y.Z`
6. Push the stable branch commit and tag
7. Create the GitHub Release
8. Merge `release/X.Y.Z` back to `master` without squash or rebase
9. Complete website and announcement surfaces
Critical consequence:
- Canaries do **not** use promote-by-dist-tag anymore.
- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`.
## Step 1 — Decide the Stable Version
Start the release train first:
```bash
./scripts/release-start.sh {patch|minor|major}
```
Then run release preflight:
```bash
./scripts/release-preflight.sh canary {patch|minor|major}
# or
./scripts/release-preflight.sh stable {patch|minor|major}
```
Then use the last stable tag as the base:
```bash
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
git log "${LAST_TAG}..HEAD" --oneline --no-merges
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
Bump policy:
- destructive migrations, removed APIs, breaking config changes -> `major`
- additive migrations or clearly user-visible features -> at least `minor`
- fixes only -> `patch`
If the requested bump is too low, escalate it and explain why.
## Step 2 — Draft the Stable Changelog
Invoke `release-changelog` and generate:
- `releases/vX.Y.Z.md`
Rules:
- review the draft with a human before publish
- preserve manual edits if the file already exists
- keep the heading and filename stable-only, for example `v1.2.3`
- do not create a separate canary changelog file
## Step 3 — Verify the Release SHA
Run the standard gate:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes.
The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping.
## Step 4 — Publish a Canary
Run from the `release/X.Y.Z` branch:
```bash
./scripts/release.sh {patch|minor|major} --canary --dry-run
./scripts/release.sh {patch|minor|major} --canary
```
What this means:
- npm receives `X.Y.Z-canary.N` under dist-tag `canary`
- `latest` remains unchanged
- no git tag is created
- the script cleans the working tree afterward
Guard:
- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0`
- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable
After publish, verify:
```bash
npm view paperclipai@canary version
```
The user install path is:
```bash
npx paperclipai@canary onboard
```
## Step 5 — Smoke Test the Canary
Run:
```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Confirm:
1. install succeeds
2. onboarding completes
3. server boots
4. UI loads
5. basic company/dashboard flow works
If smoke testing fails:
- stop the stable release
- fix the issue
- publish another canary
- repeat the smoke test
Each retry should create a higher canary ordinal, while the stable target version can stay the same.
## Step 6 — Publish Stable
Once the SHA is vetted, run:
```bash
./scripts/release.sh {patch|minor|major} --dry-run
./scripts/release.sh {patch|minor|major}
```
Stable publish does this:
- publishes `X.Y.Z` to npm under `latest`
- creates the local release commit
- creates the local git tag `vX.Y.Z`
Stable publish does **not** push the release for you.
## Step 7 — Push and Create GitHub Release
After stable publish succeeds:
```bash
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
Use the stable changelog file as the GitHub Release notes source.
Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase.
## Step 8 — Finish the Other Surfaces
Create or verify follow-up work for:
- website changelog publishing
- launch post / social announcement
- any release summary in Paperclip issue context
These should reference the stable release, not the canary.
## Failure Handling
If the canary is bad:
- publish another canary, do not ship stable
If stable npm publish succeeds but push or GitHub release creation fails:
- fix the git/GitHub issue immediately from the same checkout
- do not republish the same version
If `latest` is bad after stable publish:
```bash
./scripts/rollback-latest.sh <last-good-version>
```
Then fix forward with a new patch release.
## Output
When the skill completes, provide:
- stable version and, if relevant, the final canary version tested
- verification status
- npm status
- git tag / GitHub Release status
- website / announcement follow-up status
- rollback recommendation if anything is still partially complete

View File

@@ -1,5 +0,0 @@
---
"@paperclipai/shared": minor
---
Add support for Pi local adapter in constants and onboarding UI.

44
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: E2E Tests
on:
workflow_dispatch:
inputs:
skip_llm:
description: "Skip LLM-dependent assertions (default: true)"
type: boolean
default: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npx playwright install --with-deps chromium
- name: Run e2e tests
run: pnpm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14

View File

@@ -32,6 +32,7 @@ jobs:
node-version: 20
- name: Block manual lockfile edits
if: github.head_ref != 'chore/refresh-lockfile'
run: |
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

View File

@@ -11,11 +11,12 @@ concurrency:
cancel-in-progress: false
jobs:
refresh_and_verify:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
@@ -40,6 +41,7 @@ jobs:
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
@@ -48,29 +50,32 @@ jobs:
exit 1
fi
- name: Commit refreshed lockfile
- 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 || {
echo "Push failed because master moved during lockfile refresh."
echo "A later refresh run should recompute the lockfile from the newer master state."
exit 1
}
git push --force origin "$BRANCH"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
# 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

132
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Release
on:
workflow_dispatch:
inputs:
channel:
description: Release channel
required: true
type: choice
default: canary
options:
- canary
- stable
bump:
description: Semantic version bump
required: true
type: choice
default: patch
options:
- patch
- minor
- major
dry_run:
description: Preview the release without publishing
required: true
type: boolean
default: true
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
if: startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish:
if: startsWith(github.ref, 'refs/heads/release/')
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-release
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Run release script
env:
GITHUB_ACTIONS: "true"
run: |
args=("${{ inputs.bump }}")
if [ "${{ inputs.channel }}" = "canary" ]; then
args+=("--canary")
fi
if [ "${{ inputs.dry_run }}" = "true" ]; then
args+=("--dry-run")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable release branch commit and tag
if: inputs.channel == 'stable' && !inputs.dry_run
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
- name: Create GitHub Release
if: inputs.channel == 'stable' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"

6
.gitignore vendored
View File

@@ -36,4 +36,8 @@ tmp/
*.tmp
.vscode/
.claude/settings.local.json
.paperclip-local/
.paperclip-local/
# Playwright
tests/e2e/test-results/
tests/e2e/playwright-report/

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,8 +16,11 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
RUN pnpm install --frozen-lockfile
FROM base AS build
@@ -30,8 +33,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" &
FROM base AS production
WORKDIR /app
COPY --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
ENV NODE_ENV=production \
HOME=/paperclip \
@@ -47,4 +52,5 @@ ENV NODE_ENV=production \
VOLUME ["/paperclip"]
EXPOSE 3100
USER node
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]

View File

@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
<!-- TODO: add CONTRIBUTING.md -->
<br/>
## Community

View File

@@ -1,5 +1,26 @@
# paperclipai
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
- @paperclipai/server@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "paperclipai",
"version": "0.2.7",
"version": "0.3.0",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
@@ -37,6 +37,7 @@
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
@@ -47,6 +48,7 @@
"drizzle-orm": "0.38.4",
"dotenv": "^17.0.1",
"commander": "^13.1.0",
"embedded-postgres": "^18.1.0-beta.16",
"picocolors": "^1.1.1"
},
"devDependencies": {

View File

@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",

View File

@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { doctor } from "../commands/doctor.js";
import { writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createTempConfig(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
const configPath = path.join(root, ".paperclip", "config.json");
const runtimeRoot = path.join(root, "runtime");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3199,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
writeConfig(config, configPath);
return configPath;
}
describe("doctor", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
const configPath = createTempConfig();
const summary = await doctor({
config: configPath,
repair: true,
yes: true,
});
expect(summary.failed).toBe(0);
expect(summary.warned).toBe(0);
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
});
});

View File

@@ -0,0 +1,405 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
rebindWorkspaceCwd,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
worktreeMakeCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
sanitizeWorktreeInstanceId,
} from "../commands/worktree-lib.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function buildSourceConfig(): PaperclipConfig {
return {
$meta: {
version: 1,
updatedAt: "2026-03-09T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/main/db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/main/backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/main/logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: ["localhost"],
serveUi: true,
},
auth: {
baseUrlMode: "explicit",
publicBaseUrl: "http://127.0.0.1:3100",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/tmp/main/storage",
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: "/tmp/main/secrets/master.key",
},
},
};
}
describe("worktree helpers", () => {
it("sanitizes instance ids", () => {
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
});
it("resolves worktree:make target paths under the user home directory", () => {
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
path.resolve(os.homedir(), "paperclip-pr-432"),
);
});
it("rejects worktree:make names that are not safe directory/branch names", () => {
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
});
it("builds git worktree add args for new and existing branches", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: false,
}),
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: true,
}),
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
});
it("builds git worktree add args with a start point", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: false,
startPoint: "public-gh/master",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
});
it("uses start point even when a local branch with the same name exists", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: true,
startPoint: "origin/main",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
});
it("builds isolated config and env paths for a worktree", () => {
const paths = resolveWorktreeLocalPaths({
cwd: "/tmp/paperclip-feature",
homeDir: "/tmp/paperclip-worktrees",
instanceId: "feature-worktree-support",
});
const config = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths,
serverPort: 3110,
databasePort: 54339,
now: new Date("2026-03-09T12:00:00.000Z"),
});
expect(config.database.embeddedPostgresDataDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
);
expect(config.database.embeddedPostgresPort).toBe(54339);
expect(config.server.port).toBe(3110);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
expect(config.storage.localDisk.baseDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths);
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(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
expect(minimal.excludedTables).toContain("heartbeat_runs");
expect(minimal.excludedTables).toContain("heartbeat_run_events");
expect(minimal.excludedTables).toContain("workspace_runtime_services");
expect(minimal.excludedTables).toContain("agent_task_sessions");
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
try {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
if (originalInlineMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
}
if (originalKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
try {
fs.mkdirSync(repoRoot, { recursive: true });
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: path.join(tempRoot, ".paperclip-worktrees"),
});
const envPath = path.join(repoRoot, ".paperclip", ".env");
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
} finally {
process.chdir(originalCwd);
if (originalJwtSecret === undefined) {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
} else {
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
it("copies shared git hooks into a linked worktree git dir", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(tempRoot, "repo-feature");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
fs.chmodSync(sourceHookPath, 0o755);
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
expect(copied).toMatchObject({
sourceHooksPath: resolvedSourceHooksDir,
targetHooksPath: resolvedTargetHooksDir,
copied: true,
});
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
} finally {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
const repoRoot = path.join(tempRoot, "repo");
const fakeHome = path.join(tempRoot, "home");
const worktreePath = path.join(fakeHome, "paperclip-make-test");
const originalCwd = process.cwd();
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeHome, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.chdir(repoRoot);
await worktreeMakeCommand("paperclip-make-test", {
seed: false,
home: path.join(tempRoot, ".paperclip-worktrees"),
});
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
homedirSpy.mockRestore();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
});

View File

@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printCursorStreamEvent,
};
const geminiLocalCLIAdapter: CLIAdapterModule = {
type: "gemini_local",
formatStdoutEvent: printGeminiStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
@@ -45,6 +51,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
geminiLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,

View File

@@ -104,8 +104,10 @@ export class PaperclipApiClient {
function buildUrl(apiBase: string, path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const [pathname, query] = normalizedPath.split("?");
const url = new URL(apiBase);
url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`;
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
if (query) url.search = query;
return url.toString();
}

View File

@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
);
}
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {

View File

@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
function hashToken(token: string) {
@@ -13,7 +14,8 @@ function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
}
function resolveDbUrl(configPath?: string) {
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
if (explicitDbUrl) return explicitDbUrl;
const config = readConfig(configPath);
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
if (config?.database.mode === "postgres" && config.database.connectionString) {
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
force?: boolean;
expiresHours?: number;
baseUrl?: string;
dbUrl?: string;
}) {
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
return;
}
const dbUrl = resolveDbUrl(configPath);
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
if (!dbUrl) {
p.log.error(
"Could not resolve database connection for bootstrap.",
@@ -71,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const existingAdminCount = await db
.select()
@@ -118,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => 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

@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),

View File

@@ -14,6 +14,7 @@ import {
storageCheck,
type CheckResult,
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
const STATUS_ICON = {
@@ -31,6 +32,7 @@ export async function doctor(opts: {
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const results: CheckResult[] = [];
// 1. Config check (must pass before others)
@@ -64,28 +66,40 @@ export async function doctor(opts: {
printResult(deploymentAuthResult);
// 3. Agent JWT check
const jwtResult = agentJwtSecretCheck(opts.config);
results.push(jwtResult);
printResult(jwtResult);
await maybeRepair(jwtResult, opts);
results.push(
await runRepairableCheck({
run: () => agentJwtSecretCheck(opts.config),
configPath,
opts,
}),
);
// 4. Secrets adapter check
const secretsResult = secretsCheck(config, configPath);
results.push(secretsResult);
printResult(secretsResult);
await maybeRepair(secretsResult, opts);
results.push(
await runRepairableCheck({
run: () => secretsCheck(config, configPath),
configPath,
opts,
}),
);
// 5. Storage check
const storageResult = storageCheck(config, configPath);
results.push(storageResult);
printResult(storageResult);
await maybeRepair(storageResult, opts);
results.push(
await runRepairableCheck({
run: () => storageCheck(config, configPath),
configPath,
opts,
}),
);
// 6. Database check
const dbResult = await databaseCheck(config, configPath);
results.push(dbResult);
printResult(dbResult);
await maybeRepair(dbResult, opts);
results.push(
await runRepairableCheck({
run: () => databaseCheck(config, configPath),
configPath,
opts,
}),
);
// 7. LLM check
const llmResult = await llmCheck(config);
@@ -93,10 +107,13 @@ export async function doctor(opts: {
printResult(llmResult);
// 8. Log directory check
const logResult = logCheck(config, configPath);
results.push(logResult);
printResult(logResult);
await maybeRepair(logResult, opts);
results.push(
await runRepairableCheck({
run: () => logCheck(config, configPath),
configPath,
opts,
}),
);
// 9. Port check
const portResult = await portCheck(config);
@@ -118,9 +135,9 @@ function printResult(result: CheckResult): void {
async function maybeRepair(
result: CheckResult,
opts: { repair?: boolean; yes?: boolean },
): Promise<void> {
if (result.status === "pass" || !result.canRepair || !result.repair) return;
if (!opts.repair) return;
): Promise<boolean> {
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
if (!opts.repair) return false;
let shouldRepair = opts.yes;
if (!shouldRepair) {
@@ -128,7 +145,7 @@ async function maybeRepair(
message: `Repair "${result.name}"?`,
initialValue: true,
});
if (p.isCancel(answer)) return;
if (p.isCancel(answer)) return false;
shouldRepair = answer;
}
@@ -136,10 +153,30 @@ async function maybeRepair(
try {
await result.repair();
p.log.success(`Repaired: ${result.name}`);
return true;
} catch (err) {
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
return false;
}
async function runRepairableCheck(input: {
run: () => CheckResult | Promise<CheckResult>;
configPath: string;
opts: { repair?: boolean; yes?: boolean };
}): Promise<CheckResult> {
let result = await input.run();
printResult(result);
const repaired = await maybeRepair(result, input.opts);
if (!repaired) return result;
// Repairs may create/update the adjacent .env file or other local resources.
loadPaperclipEnvFile(input.configPath);
result = await input.run();
printResult(result);
return result;
}
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {

View File

@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
},
auth: {
baseUrlMode: authBaseUrlMode,
disableSignUp: false,
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
},
storage: {
@@ -228,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
return { defaults, usedEnvKeys, ignoredEnvKeys };
}
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export async function onboard(opts: OnboardOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
@@ -449,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
if (server.deploymentMode === "authenticated") {
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath });
}
@@ -472,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
return;
}
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
p.log.info(
[
"Bootstrap CEO invite will be created after the server starts.",
`Next: ${pc.cyan("paperclipai run")}`,
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
].join("\n"),
);
}
p.outro("You're all set!");
}

View File

@@ -3,9 +3,13 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { onboard } from "./onboard.js";
import { doctor } from "./doctor.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig } from "../config/store.js";
import {
describeLocalInstancePaths,
resolvePaperclipHomeDir,
@@ -19,6 +23,13 @@ interface RunOptions {
yes?: boolean;
}
interface StartedServer {
apiUrl: string;
databaseUrl: string;
host: string;
listenPort: number;
}
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
@@ -60,8 +72,41 @@ export async function runCommand(opts: RunOptions): Promise<void> {
process.exit(1);
}
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}.`);
process.exit(1);
}
p.log.step("Starting Paperclip server...");
await importServerEntry();
const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({
config: configPath,
dbUrl: startedServer.databaseUrl,
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
});
}
}
function resolveBootstrapInviteBaseUrl(
config: PaperclipConfig,
startedServer: StartedServer,
): string {
const explicitBaseUrl =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
return explicitBaseUrl.trim().replace(/\/+$/, "");
}
return startedServer.apiUrl.replace(/\/api$/, "");
}
function formatError(err: unknown): string {
@@ -101,19 +146,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
}
}
async function importServerEntry(): Promise<void> {
async function importServerEntry(): Promise<StartedServer> {
// Dev mode: try local workspace path (monorepo with tsx)
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
if (fs.existsSync(devEntry)) {
maybeEnableUiDevMiddleware(devEntry);
await import(pathToFileURL(devEntry).href);
return;
const mod = await import(pathToFileURL(devEntry).href);
return await startServerFromModule(mod, devEntry);
}
// Production mode: import the published @paperclipai/server package
try {
await import("@paperclipai/server");
const mod = await import("@paperclipai/server");
return await startServerFromModule(mod, "@paperclipai/server");
} catch (err) {
const missingSpecifier = getMissingModuleSpecifier(err);
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
@@ -130,3 +176,15 @@ async function importServerEntry(): Promise<void> {
);
}
}
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
}
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
if (typeof startServer !== "function") {
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
}
return await startServer();
}

View File

@@ -0,0 +1,218 @@
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import { expandHomePrefix } from "../config/home.js";
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
export type WorktreeSeedPlan = {
mode: WorktreeSeedMode;
excludedTables: string[];
nullifyColumns: Record<string, string[]>;
};
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
"activity_log",
"agent_runtime_state",
"agent_task_sessions",
"agent_wakeup_requests",
"cost_events",
"heartbeat_run_events",
"heartbeat_runs",
"workspace_runtime_services",
];
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
issues: ["checkout_run_id", "execution_run_id"],
};
export type WorktreeLocalPaths = {
cwd: string;
repoConfigDir: string;
configPath: string;
envPath: string;
homeDir: string;
instanceId: string;
instanceRoot: string;
contextPath: string;
embeddedPostgresDataDir: string;
backupDir: string;
logDir: string;
secretsKeyFilePath: string;
storageDir: string;
};
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
}
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
if (mode === "full") {
return {
mode,
excludedTables: [],
nullifyColumns: {},
};
}
return {
mode,
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
nullifyColumns: {
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
},
};
}
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
export function sanitizeWorktreeInstanceId(rawValue: string): string {
const trimmed = rawValue.trim().toLowerCase();
const normalized = trimmed
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
return normalized || "worktree";
}
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
}
export function resolveWorktreeLocalPaths(opts: {
cwd: string;
homeDir?: string;
instanceId: string;
}): WorktreeLocalPaths {
const cwd = path.resolve(opts.cwd);
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
const repoConfigDir = path.resolve(cwd, ".paperclip");
return {
cwd,
repoConfigDir,
configPath: path.resolve(repoConfigDir, "config.json"),
envPath: path.resolve(repoConfigDir, ".env"),
homeDir,
instanceId: opts.instanceId,
instanceRoot,
contextPath: path.resolve(homeDir, "context.json"),
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
backupDir: path.resolve(instanceRoot, "data", "backups"),
logDir: path.resolve(instanceRoot, "logs"),
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
storageDir: path.resolve(instanceRoot, "data", "storage"),
};
}
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
return rawUrl;
}
}
export function buildWorktreeConfig(input: {
sourceConfig: PaperclipConfig | null;
paths: WorktreeLocalPaths;
serverPort: number;
databasePort: number;
now?: Date;
}): PaperclipConfig {
const { sourceConfig, paths, serverPort, databasePort } = input;
const nowIso = (input.now ?? new Date()).toISOString();
const source = sourceConfig;
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
return {
$meta: {
version: 1,
updatedAt: nowIso,
source: "configure",
},
...(source?.llm ? { llm: source.llm } : {}),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
embeddedPostgresPort: databasePort,
backup: {
enabled: source?.database.backup.enabled ?? true,
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
retentionDays: source?.database.backup.retentionDays ?? 30,
dir: paths.backupDir,
},
},
logging: {
mode: source?.logging.mode ?? "file",
logDir: paths.logDir,
},
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],
serveUi: source?.server.serveUi ?? true,
},
auth: {
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
disableSignUp: source?.auth.disableSignUp ?? false,
},
storage: {
provider: source?.storage.provider ?? "local_disk",
localDisk: {
baseDir: paths.storageDir,
},
s3: {
bucket: source?.storage.s3.bucket ?? "paperclip",
region: source?.storage.s3.region ?? "us-east-1",
endpoint: source?.storage.s3.endpoint,
prefix: source?.storage.s3.prefix ?? "",
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
},
},
secrets: {
provider: source?.secrets.provider ?? "local_encrypted",
strictMode: source?.secrets.strictMode ?? false,
localEncrypted: {
keyFilePath: paths.secretsKeyFilePath,
},
},
};
}
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): 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",
};
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function formatShellExports(entries: Record<string, string>): string {
return Object.entries(entries)
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
.join("\n");
}

1112
cli/src/commands/worktree.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,17 +25,25 @@ function parseEnvFile(contents: string) {
function renderEnvFile(entries: Record<string, string>) {
const lines = [
"# Paperclip environment variables",
"# Generated by `paperclipai onboard`",
"# Generated by Paperclip CLI commands",
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
"",
];
return lines.join("\n");
}
export function resolvePaperclipEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function resolveAgentJwtEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function loadPaperclipEnvFile(configPath?: string): void {
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
}
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
if (loadedEnvFiles.has(filePath)) return;
@@ -78,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
}
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
}
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
if (!fs.existsSync(filePath)) return {};
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
}
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
current[JWT_SECRET_ENV_KEY] = secret;
fs.writeFileSync(filePath, renderEnvFile(current), {
fs.writeFileSync(filePath, renderEnvFile(entries), {
mode: 0o600,
});
}
export function mergePaperclipEnvEntries(
entries: Record<string, string>,
filePath = resolveEnvFilePath(),
): Record<string, string> {
const current = readPaperclipEnvEntries(filePath);
const next = {
...current,
...Object.fromEntries(
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
),
};
writePaperclipEnvEntries(next, filePath);
return next;
}

View File

@@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
import { registerActivityCommands } from "./commands/client/activity.js";
import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
hasConfigOption: optionNames.has("config"),
hasContextOption: optionNames.has("context"),
});
loadPaperclipEnvFile(options.config);
});
program
@@ -132,6 +135,7 @@ registerAgentCommands(program);
registerApprovalCommands(program);
registerActivityCommands(program);
registerDashboardCommands(program);
registerWorktreeCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");

View File

@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
}
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto" };
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
@@ -139,11 +139,13 @@ export async function promptServer(opts?: {
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
}
@@ -160,4 +162,3 @@ export async function promptServer(opts?: {
auth,
};
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../tsconfig.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -19,6 +19,14 @@ That's it. On first start the server:
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
If you need to apply pending migrations manually, run:
```sh
pnpm db:migrate
```
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
This mode is ideal for local development and one-command installs.
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).

View File

@@ -124,6 +124,113 @@ When a local agent run has no resolved project/session workspace, Paperclip fall
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
Instead, create a repo-local Paperclip config plus an isolated instance for the worktree:
```sh
paperclipai worktree init
# or create the git worktree and initialize it in one step:
pnpm paperclipai worktree:make paperclip-pr-432
```
This command:
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
- picks a free app port and embedded PostgreSQL port
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
Seed modes:
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
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.
Print shell exports explicitly when needed:
```sh
paperclipai worktree env
# or:
eval "$(paperclipai worktree env)"
```
### Worktree CLI Reference
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
| Option | Description |
|---|---|
| `--name <name>` | Display name used to derive the instance id |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
```sh
paperclipai worktree init --no-seed
paperclipai worktree init --seed-mode full
paperclipai worktree init --from-instance default
paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force
```
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |
|---|---|
| `--start-point <ref>` | Remote ref to base the new branch on (e.g. `origin/main`) |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
```sh
pnpm paperclipai worktree:make paperclip-pr-432
pnpm paperclipai worktree:make my-feature --start-point origin/main
pnpm paperclipai worktree:make experiment --no-seed
```
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
| Option | Description |
|---|---|
| `-c, --config <path>` | Path to config file |
| `--json` | Print JSON instead of shell exports |
Examples:
```sh
pnpm paperclipai worktree env
pnpm paperclipai worktree env --json
eval "$(pnpm paperclipai worktree env)"
```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## Quick Health Checks
In another terminal:

View File

@@ -122,5 +122,7 @@ Notes:
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:<HOST_PORT>` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`.
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
- The image definition is in `Dockerfile.onboard-smoke`.

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

@@ -1,196 +1,121 @@
# Publishing to npm
This document covers how to build and publish the `paperclipai` CLI package to npm.
Low-level reference for how Paperclip packages are built for npm.
## Prerequisites
For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts.
- Node.js 20+
- pnpm 9.15+
- An npm account with publish access to the `paperclipai` package
- Logged in to npm: `npm login`
## Current Release Entry Points
## One-Command Publish
Use these scripts instead of older one-off publish commands:
The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot:
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
```bash
./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2
./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0
./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0
./scripts/bump-and-publish.sh 2.0.0 # set explicit version
./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish
```
## Why the CLI needs special packaging
The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push:
The CLI package, `paperclipai`, imports code from workspace packages such as:
```bash
git push && git push origin v<version>
```
- `@paperclipai/server`
- `@paperclipai/db`
- `@paperclipai/shared`
- adapter packages under `packages/adapters/`
## Manual Step-by-Step
Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package.
If you prefer to run each step individually:
## `build-npm.sh`
### Quick Reference
```bash
# Bump version
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
# Build
./scripts/build-npm.sh
# Preview what will be published
cd cli && npm pack --dry-run
# Publish
cd cli && npm publish --access public
# Restore dev package.json
mv cli/package.dev.json cli/package.json
```
## Step-by-Step
### 1. Bump the version
```bash
./scripts/version-bump.sh <patch|minor|major|X.Y.Z>
```
This updates the version in two places:
- `cli/package.json` — the source of truth
- `cli/src/index.ts` — the Commander `.version()` call
Examples:
```bash
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
./scripts/version-bump.sh minor # 0.1.0 → 0.2.0
./scripts/version-bump.sh major # 0.1.0 → 1.0.0
./scripts/version-bump.sh 1.2.3 # set explicit version
```
### 2. Build
Run:
```bash
./scripts/build-npm.sh
```
The build script runs five steps:
This script does six things:
1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for.
2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages.
3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports.
4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below).
5. **Summary** — prints the bundle size and next steps.
1. Runs the forbidden token check unless `--skip-checks` is supplied
2. Runs `pnpm -r typecheck`
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
4. Verifies the bundled entrypoint with `node --check`
5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
6. Copies the repo `README.md` into `cli/README.md` for npm package metadata
To skip the forbidden token check (e.g. in CI without the token list):
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
## Publishable CLI layout
During development, [`cli/package.json`](../cli/package.json) contains workspace references.
During release preparation:
- `cli/package.json` becomes a publishable manifest with external npm dependency ranges
- `cli/package.dev.json` stores the development manifest temporarily
- `cli/dist/index.js` contains the bundled CLI entrypoint
- `cli/README.md` is copied in for npm metadata
After release finalization, the release script restores the development manifest and removes the temporary README copy.
## Package discovery
The release tooling scans the workspace for public packages under:
- `packages/`
- `server/`
- `cli/`
`ui/` remains ignored for npm publishing because it is private.
This matters because all public packages are versioned and published together as one release unit.
## Canary packaging model
Canaries are published as semver prereleases such as:
- `1.2.3-canary.0`
- `1.2.3-canary.1`
They are published under the npm dist-tag `canary`.
This means:
- `npx paperclipai@canary onboard` can install them explicitly
- `npx paperclipai onboard` continues to resolve `latest`
- the stable changelog can stay at `releases/v1.2.3.md`
## Stable packaging model
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
## Rollback model
Rollback does not unpublish packages.
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
```bash
./scripts/build-npm.sh --skip-checks
./scripts/rollback-latest.sh <stable-version>
```
### 3. Preview (optional)
That keeps history intact while restoring the default install path quickly.
See what npm will publish:
## Notes for CI
```bash
cd cli && npm pack --dry-run
```
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
### 4. Publish
Recommended CI release setup:
```bash
cd cli && npm publish --access public
```
- use npm trusted publishing via GitHub OIDC
- require approval through the `npm-release` environment
- run releases from `release/X.Y.Z`
- use canary first, then stable
### 5. Restore dev package.json
## Related Files
After publishing, restore the workspace-aware `package.json`:
```bash
mv cli/package.dev.json cli/package.json
```
### 6. Commit and tag
```bash
git add cli/package.json cli/src/index.ts
git commit -m "chore: bump version to X.Y.Z"
git tag vX.Y.Z
```
## package.dev.json
During development, `cli/package.json` contains `workspace:*` references like:
```json
{
"dependencies": {
"@paperclipai/server": "workspace:*",
"@paperclipai/db": "workspace:*"
}
}
```
These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users.
The build script solves this with a two-file swap:
1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version).
2. **During build (`build-npm.sh` step 4):**
- The dev `package.json` is copied to `package.dev.json` as a backup.
- `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs.
3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`.
The generated publishable `package.json` looks like:
```json
{
"name": "paperclipai",
"version": "0.1.0",
"bin": { "paperclipai": "./dist/index.js" },
"dependencies": {
"express": "^5.1.0",
"postgres": "^3.4.5",
"commander": "^13.1.0"
}
}
```
`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle.
## How the bundle works
The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm.
**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`.
The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle).
## Forbidden token enforcement
The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm.
- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported)
- The file lives inside `.git/` and is never committed
- If the file is missing, the check passes — contributors without the list can still build
- The script never prints which tokens are being searched for
- Matches are printed so you know which files to fix, but not which token triggered it
Run the check standalone:
```bash
pnpm check:tokens
```
## npm scripts reference
| Script | Command | Description |
|---|---|---|
| `bump-and-publish` | `pnpm bump-and-publish <type>` | One-command bump + build + publish + commit + tag |
| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) |
| `version:bump` | `pnpm version:bump <type>` | Bump CLI version |
| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only |
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

422
doc/RELEASING.md Normal file
View File

@@ -0,0 +1,422 @@
# Releasing Paperclip
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
The release model is branch-driven:
1. Start a release train on `release/X.Y.Z`
2. Draft the stable changelog on that branch
3. Publish one or more canaries from that branch
4. Publish stable from that same branch head
5. Push the branch commit and tag
6. Create the GitHub Release
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
## Release Surfaces
Every release has four separate surfaces:
1. **Verification** — the exact git SHA passes typecheck, tests, and build
2. **npm**`paperclipai` and public workspace packages are published
3. **GitHub** — the stable release gets a git tag and GitHub Release
4. **Website / announcements** — the stable changelog is published externally and announced
A release is done only when all four surfaces are handled.
## Core Invariants
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
- The release scripts must run from the matching `release/X.Y.Z` branch.
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
## TL;DR
### 1. Start the release train
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
```bash
./scripts/release-start.sh patch
```
That script:
- fetches the release remote and tags
- computes the next stable version from the latest `v*` tag
- creates or resumes `release/X.Y.Z`
- creates or resumes a dedicated worktree
- pushes the branch to the remote by default
- refuses to reuse a frozen release train
### 2. Draft the stable changelog
From the release worktree:
```bash
VERSION=X.Y.Z
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
### 3. Verify and publish a canary
```bash
./scripts/release-preflight.sh canary patch
./scripts/release.sh patch --canary --dry-run
./scripts/release.sh patch --canary
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Users install canaries with:
```bash
npx paperclipai@canary onboard
```
### 4. Publish stable
```bash
./scripts/release-preflight.sh stable patch
./scripts/release.sh patch --dry-run
./scripts/release.sh patch
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
## Release Branches
Paperclip uses one release branch per target stable version:
- `release/0.3.0`
- `release/0.3.1`
- `release/1.0.0`
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
## Script Entry Points
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
## Detailed Workflow
### 1. Start or resume the release train
Run:
```bash
./scripts/release-start.sh <patch|minor|major>
```
Useful options:
```bash
./scripts/release-start.sh patch --dry-run
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
./scripts/release-start.sh patch --no-push
```
The script is intentionally idempotent:
- if `release/X.Y.Z` already exists locally, it reuses it
- if the branch already exists on the remote, it resumes it locally
- if the branch is already checked out in another worktree, it points you there
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
### 2. Write the stable changelog early
Create or update:
- `releases/vX.Y.Z.md`
That file is for the eventual stable release. It should not include `-canary` in the filename or heading.
Recommended structure:
- `Breaking Changes` when needed
- `Highlights`
- `Improvements`
- `Fixes`
- `Upgrade Guide` when needed
- `Contributors` — @-mention every contributor by GitHub username (no emails)
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
### 3. Run release preflight
From the `release/X.Y.Z` worktree:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
# or
./scripts/release-preflight.sh stable <patch|minor|major>
```
The preflight script now checks all of the following before it runs the verification gate:
- the worktree is clean, including untracked files
- the current branch matches the computed `release/X.Y.Z`
- the release train is not frozen
- the target version is still free on npm
- the target tag does not already exist locally or remotely
- whether the remote release branch already exists
- whether `releases/vX.Y.Z.md` is present
Then it runs:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
### 4. Publish one or more canaries
Run:
```bash
./scripts/release.sh <patch|minor|major> --canary --dry-run
./scripts/release.sh <patch|minor|major> --canary
```
Result:
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
- `latest` is unchanged
- no git tag is created
- no GitHub Release is created
- the worktree returns to clean after the script finishes
Guardrails:
- the script refuses to run from the wrong branch
- the script refuses to publish from a frozen train
- the canary is always derived from the next stable version
- if the stable notes file is missing, the script warns before you forget it
Concrete example:
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
### 5. Smoke test the canary
Run the actual install path in Docker:
```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Useful isolated variants:
```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
If you want to exercise onboarding from the current committed ref instead of npm, use:
```bash
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
Minimum checks:
- `npx paperclipai@canary onboard` installs
- onboarding completes without crashes
- the server boots
- the UI loads
- basic company creation and dashboard load work
If smoke testing fails:
1. stop the stable release
2. fix the issue on the same `release/X.Y.Z` branch
3. publish another canary
4. rerun smoke testing
### 6. Publish stable from the same release branch
Once the branch head is vetted, run:
```bash
./scripts/release.sh <patch|minor|major> --dry-run
./scripts/release.sh <patch|minor|major>
```
Stable publish:
- publishes `X.Y.Z` to npm under `latest`
- creates the local release commit
- creates the local tag `vX.Y.Z`
Stable publish refuses to proceed if:
- the current branch is not `release/X.Y.Z`
- the remote release branch does not exist yet
- the stable notes file is missing
- the target tag already exists locally or remotely
- the stable version already exists on npm
Those checks intentionally freeze the train after stable publish.
### 7. Push the stable branch commit and tag
After stable publish succeeds:
```bash
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
The GitHub Release notes come from:
- `releases/vX.Y.Z.md`
### 8. Merge the release branch back to `master`
Open a PR:
- base: `master`
- head: `release/X.Y.Z`
Merge rule:
- allowed: merge commit or fast-forward
- forbidden: squash merge
- forbidden: rebase merge
Post-merge verification:
```bash
git fetch public-gh --tags
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
```
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
### 9. Finish the external surfaces
After GitHub is correct:
- publish the changelog on the website
- write and send the announcement copy
- ensure public docs and install guidance point to the stable version
## GitHub Actions Release
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
1. Choose `Release`
2. Choose `channel`: `canary` or `stable`
3. Choose `bump`: `patch`, `minor`, or `major`
4. Choose whether this is a `dry_run`
5. Run it from the release branch, not from `master`
The workflow:
- reruns `typecheck`, `test:run`, and `build`
- gates publish behind the `npm-release` environment
- can publish canaries without touching `latest`
- can publish stable, push the stable branch commit and tag, and create the GitHub Release
It does not merge the release branch back to `master` for you.
## Release Checklist
### Before any publish
- [ ] The release train exists on `release/X.Y.Z`
- [ ] The working tree is clean, including untracked files
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
- [ ] The required verification gate passed on the exact branch head you want to publish
- [ ] The bump type is correct for the user-visible impact
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
- [ ] You know which previous stable version you would roll back to if needed
### Before a stable
- [ ] The candidate has already passed smoke testing
- [ ] The remote `release/X.Y.Z` branch exists
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
- [ ] You are ready to create the GitHub Release immediately after the push
- [ ] You are ready to open the PR back to `master`
### After a stable
- [ ] `npm view paperclipai@latest version` matches the new stable version
- [ ] The git tag exists on GitHub
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
- [ ] `vX.Y.Z` is reachable from `master`
- [ ] The website changelog is updated
- [ ] Announcement copy matches the stable release, not the canary
## Failure Playbooks
### If the canary publishes but the smoke test fails
Do not publish stable.
Instead:
1. fix the issue on `release/X.Y.Z`
2. publish another canary
3. rerun smoke testing
### If stable npm publish succeeds but push or GitHub release creation fails
This is a partial release. npm is already live.
Do this immediately:
1. fix the git or GitHub issue from the same checkout
2. push the stable branch commit and tag
3. create the GitHub Release
Do not republish the same version.
### If `latest` is broken after stable publish
Preview:
```bash
./scripts/rollback-latest.sh X.Y.Z --dry-run
```
Roll back:
```bash
./scripts/rollback-latest.sh X.Y.Z
```
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
Then fix forward with a new patch release.
### If the GitHub Release notes are wrong
Re-run:
```bash
./scripts/create-github-release.sh X.Y.Z
```
If the release already exists, the script updates it.
## Related Docs
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow

View File

@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Visibility | Full visibility to board and all agents in same company |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
| Agent adapters | Built-in `process` and `http` adapters |
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
| Budget period | Monthly UTC calendar window |
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
- heartbeat trigger checks
- stuck run detection
- budget threshold checks
- stale task reporting generation
Separate queue infrastructure is not required for V1.
@@ -502,7 +501,6 @@ Dashboard payload must include:
- open/in-progress/blocked/done issue counts
- month-to-date spend and budget utilization
- pending approvals count
- stale task count
## 10.9 Error Semantics
@@ -681,7 +679,6 @@ Required UX behaviors:
- global company selector
- quick actions: pause/resume agent, create task, approve/reject request
- conflict toasts on atomic checkout failure
- clear stale-task indicators
- no silent background failures; every failed run visible in UI
## 15. Operational Requirements
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
- add company selector and org chart view
- add approvals and cost pages
- add operational dashboard and stale-task surfacing
## Milestone 6: Hardening and Release

View File

@@ -0,0 +1,62 @@
# Issue worktree support
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
- project execution workspace policy support
- issue-level execution workspace settings
- git worktree realization for isolated issue execution
- optional command-based worktree provisioning
- seeded worktree fixes for secrets key compatibility
- seeded project workspace rebinding to the current git worktree
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
## What works today
- projects can carry execution workspace policy in the backend
- issues can carry execution workspace settings in the backend
- heartbeat execution can realize isolated git worktrees
- runtime can run a project-defined provision command inside the derived worktree
- seeded worktree instances can keep local-encrypted secrets working
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
## Hidden UI entrypoints
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
- project settings:
- `ui/src/components/ProjectProperties.tsx`
- execution workspace policy controls
- git worktree base ref / branch template / parent dir
- provision / teardown command inputs
- issue creation:
- `ui/src/components/NewIssueDialog.tsx`
- isolated issue checkout toggle
- defaulting issue execution workspace settings from project policy
- issue editing:
- `ui/src/components/IssueProperties.tsx`
- issue-level workspace mode toggle
- defaulting issue execution workspace settings when project changes
- agent/runtime settings:
- `ui/src/adapters/runtime-json-fields.tsx`
- runtime services JSON field, which is part of the broader workspace-runtime support surface
## Why the UI is hidden
- the runtime behavior is still being validated
- the workflow and operator ergonomics are not final
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
## Re-enable plan
When this is ready to ship:
- re-enable the gated UI sections in the files above
- review wording and defaults for project and issue controls
- decide which agent/runtime settings should remain advanced-only
- add end-to-end product-level verification for the full UI workflow

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,329 @@
# Agent Chat UI and Issue-Backed Conversations
## Context
`PAP-475` asks two related questions:
1. What UI kit should Paperclip use if we add a chat surface with an agent?
2. How should chat fit the product without breaking the current issue-centric model?
This is not only a component-library decision. In Paperclip today:
- V1 explicitly says communication is `tasks + comments only`, with no separate chat system.
- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage.
- Live run streaming already exists on issue detail pages.
- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`.
- The OpenClaw gateway adapter already supports an issue-scoped session key strategy.
That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have."
## Current Constraints From the Codebase
### Durable work object
The durable object in Paperclip is the issue, not a chat thread.
- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline.
- `CommentThread` already renders markdown comments and supports reply/reassignment flows.
- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs.
### Session behavior
Session continuity is already task-shaped.
- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`.
- `agent_task_sessions` stores session state per company + agent + adapter + task key.
- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well.
That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system.
### Billing behavior
Billing is already issue-aware.
- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`.
- heartbeat context already propagates issue linkage into runs and cost rollups.
If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable.
## UI Kit Recommendation
## Recommendation: `assistant-ui`
Use `assistant-ui` as the chat presentation layer.
Why it fits Paperclip:
- It is a real chat UI kit, not just a hook.
- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well.
- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls.
- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering.
Why not make "the Vercel one" the primary choice:
- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols.
- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip.
- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model.
So the clean split is:
- `assistant-ui` for UI primitives
- Paperclip-owned runtime/store for state, persistence, and transport
- optional AI SDK usage later only if we want its stream protocol or client transport abstraction
## Product Options
### Option A: Separate chat object
Create a new top-level chat/thread model unrelated to issues.
Pros:
- clean mental model if users want freeform conversation
- easy to hide from issue boards
Cons:
- breaks the current V1 product decision that communication is issue-centric
- needs new persistence, billing, session, permissions, activity, and wakeup rules
- creates a second "why does this exist?" object beside issues
- makes "pick up an old chat" a separate retrieval problem
Verdict: not recommended for V1.
### Option B: Every chat is an issue
Treat chat as a UI mode over an issue. The issue remains the durable record.
Pros:
- matches current product spec
- billing, runs, comments, approvals, and activity already work
- sessions already resume on issue identity
- works with all adapters, including OpenClaw, without new agent auth or a second API surface
Cons:
- some chats are not really "tasks" in a board sense
- onboarding and review conversations may clutter normal issue lists
Verdict: best V1 foundation.
### Option C: Hybrid with hidden conversation issues
Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted.
Pros:
- preserves the issue-centric backend
- gives onboarding/review chat a cleaner UX
- preserves billing and session continuity
Cons:
- requires extra UI rules and possibly a small schema or filtering addition
- can become a disguised second system if not kept narrow
Verdict: likely the right product shape after a basic issue-backed MVP.
## Recommended Product Model
### Phase 1 product decision
For the first implementation, chat should be issue-backed.
More specifically:
- the board opens a chat surface for an issue
- sending a message is a comment mutation on that issue
- the assigned agent is woken through the existing issue-comment flow
- streaming output comes from the existing live run stream for that issue
- durable assistant output remains comments and run history, not an extra transcript store
This keeps Paperclip honest about what it is:
- the control plane stays issue-centric
- chat is a better way to interact with issue work, not a new collaboration product
### Onboarding and CEO conversations
For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab.
Suggested shape:
- create a board-initiated issue assigned to the CEO
- mark it as conversation-flavored in UI treatment
- optionally hide it from normal issue boards by default later
- keep all cost/run/session linkage on that issue
This solves several concerns at once:
- no separate API key or direct provider wiring is needed
- the same CEO adapter is used
- old conversations are recovered through normal issue history
- the CEO can still create or update real child issues from the conversation
## Session Model
### V1
Use one durable conversation session per issue.
That already matches current behavior:
- adapter task sessions persist against `taskKey`
- `taskKey` already falls back to `issueId`
- OpenClaw already supports an issue-scoped session key
This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue.
### What not to add yet
Do not add multi-thread-per-issue chat in the first pass.
If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive:
- `taskKey = issue:<issueId>:conversation:<conversationId>`
- OpenClaw `sessionKey = paperclip:conversation:<conversationId>`
Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule.
## Billing Model
Chat should not invent a separate billing pipeline.
All chat cost should continue to roll up through the issue:
- `cost_events.issueId`
- project and goal rollups through existing relationships
- issue `billingCode` when present
If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail.
This is another reason ephemeral freeform chat should not be the default.
## UI Architecture
### Recommended stack
1. Keep Paperclip as the source of truth for message history and run state.
2. Add `assistant-ui` as the rendering/composer layer.
3. Build a Paperclip runtime adapter that maps:
- issue comments -> user/assistant messages
- live run deltas -> streaming assistant messages
- issue attachments -> chat attachments
4. Keep current markdown rendering and code-block support where possible.
### Interaction flow
1. Board opens issue detail in "Chat" mode.
2. Existing comment history is mapped into chat messages.
3. When the board sends a message:
- `POST /api/issues/{id}/comments`
- optionally interrupt the active run if the UX wants "send and replace current response"
4. Existing issue comment wakeup logic wakes the assignee.
5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming.
6. When the run completes, durable state remains in comments/runs/activity as it does now.
### Why this fits the current code
Paperclip already has most of the backend pieces:
- issue comments
- run timeline
- run log and event streaming
- markdown rendering
- attachment support
- assignee wakeups on comments
The missing piece is mostly the presentation and the mapping layer, not a new backend domain.
## Agent Scope
Do not launch this as "chat with every agent."
Start narrower:
- onboarding chat with CEO
- workflow/review chat with CEO
- maybe selected exec roles later
Reasons:
- it keeps the feature from becoming a second inbox/chat product
- it limits permission and UX questions early
- it matches the stated product demand
If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly.
## Recommended Delivery Phases
### Phase 1: Chat UI on existing issues
- add a chat presentation mode to issue detail
- use `assistant-ui`
- map comments + live runs into the chat surface
- no schema change
- no new API surface
This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion.
### Phase 2: Conversation-flavored issues for CEO chat
- add a lightweight conversation classification
- support creation of CEO conversation issues from onboarding and workflow entry points
- optionally hide these from normal backlog/board views by default
The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later.
### Phase 3: Promotion and thread splitting only if needed
Only if we later see a real need:
- allow promoting a conversation to a formal task issue
- allow several threads per issue with explicit conversation identity
This should be demand-driven, not designed up front.
## Clear Recommendation
If the question is "what should we use?", the answer is:
- use `assistant-ui` for the chat UI
- do not treat raw Vercel AI SDK UI hooks as the main product answer
- keep chat issue-backed in V1
- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem
If the question is "how should we think about chat in Paperclip?", the answer is:
- chat is a mode of interacting with issue-backed agent work
- not a separate product silo
- not an excuse to stop tracing work, cost, and session history back to the issue
## Implementation Notes
### Immediate implementation target
The most defensible first build is:
- add a chat tab or chat-focused layout on issue detail
- back it with the currently assigned agent on that issue
- use `assistant-ui` primitives over existing comments and live run events
### Defer these until proven necessary
- standalone global chat objects
- multi-thread chat inside one issue
- chat with every agent in the org
- a second persistence layer for message history
- separate cost tracking for chats
## References
- V1 communication model: `doc/SPEC-implementation.md`
- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx`
- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts`
- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md`
- assistant-ui docs: <https://www.assistant-ui.com/docs>
- assistant-ui repo: <https://github.com/assistant-ui/assistant-ui>
- AI SDK transport docs: <https://ai-sdk.dev/docs/ai-sdk-ui/transport>

View File

@@ -0,0 +1,383 @@
# 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.
Current repo skill sizes:
- `skills/paperclip/SKILL.md`: 17,441 bytes
- `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.
### 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.
### 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.
## 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,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"

File diff suppressed because it is too large Load Diff

View File

@@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
"cwd": "/absolute/or/relative/path",
"promptTemplate": "You are agent {{agent.id}} ...",
"model": "optional-model-id",
"maxTurnsPerRun": 80,
"maxTurnsPerRun": 300,
"dangerouslySkipPermissions": true,
"env": {"KEY": "VALUE"},
"extraArgs": [],

View File

@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
My Issues
```
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **My Issues** — issues created by or assigned to the board operator.
### 3.3 Work Section

View File

@@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
| `graceSec` | number | No | Grace period before force-kill |
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat |
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
## Prompt Templates

View File

@@ -0,0 +1,45 @@
---
title: Gemini Local
summary: Gemini CLI local adapter setup and configuration
---
The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
## Prerequisites
- Gemini CLI installed (`gemini` command available)
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
## Configuration Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Gemini model to use. Defaults to `auto`. |
| `promptTemplate` | string | No | Prompt used for all runs |
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
| `graceSec` | number | No | Grace period before force-kill |
| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
## Session Persistence
The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
## Skills Injection
The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
## Environment Test
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
- Gemini CLI is installed and accessible
- Working directory is absolute and available (auto-created if missing and permitted)
- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness

View File

@@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
@@ -54,7 +55,7 @@ Three registries consume these modules:
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
- **Need to run a script or command?** Use `process`
- **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
"dev:watch": "node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
@@ -18,17 +18,25 @@
"db:backup": "./scripts/backup-db.sh",
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
"build:npm": "./scripts/build-npm.sh",
"release:start": "./scripts/release-start.sh",
"release": "./scripts/release.sh",
"release:preflight": "./scripts/release-preflight.sh",
"release:github": "./scripts/create-github-release.sh",
"release:rollback": "./scripts/rollback-latest.sh",
"changeset": "changeset",
"version-packages": "changeset version",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh"
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"

View File

@@ -1,5 +1,11 @@
# @paperclipai/adapter-utils
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-utils",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -3,6 +3,7 @@ export type {
AdapterRuntime,
UsageSummary,
AdapterBillingType,
AdapterRuntimeServiceReport,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
@@ -21,3 +22,9 @@ export type {
CLIAdapterModule,
CreateConfigValues,
} from "./types.js";
export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";

View File

@@ -0,0 +1,81 @@
import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]";
const HOME_PATH_PATTERNS = [
{
regex: /\/Users\/[^/\\\s]+/g,
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /\/home\/[^/\\\s]+/g,
replace: `/home/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
replace: `$1${REDACTED_HOME_PATH_USER}`,
},
] as const;
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
export function redactHomePathUserSegments(text: string): string {
let result = text;
for (const pattern of HOME_PATH_PATTERNS) {
result = result.replace(pattern.regex, pattern.replace);
}
return result;
}
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
if (typeof value === "string") {
return redactHomePathUserSegments(value) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactHomePathUserSegmentsInValue(entry);
}
return redacted as T;
}
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
switch (entry.kind) {
case "assistant":
case "thinking":
case "user":
case "stderr":
case "system":
case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) };
case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) };
case "init":
return {
...entry,
model: redactHomePathUserSegments(entry.model),
sessionId: redactHomePathUserSegments(entry.sessionId),
};
case "result":
return {
...entry,
text: redactHomePathUserSegments(entry.text),
subtype: redactHomePathUserSegments(entry.subtype),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
};
default:
return entry;
}
}

View File

@@ -15,6 +15,11 @@ interface RunningProcess {
graceSec: number;
}
interface SpawnTarget {
command: string;
args: string[];
}
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
@@ -27,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)) {
@@ -125,6 +147,78 @@ export function defaultPathForPlatform() {
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
}
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
}
async function pathExists(candidate: string) {
try {
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
return (await pathExists(absolute)) ? absolute : null;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
for (const dir of dirs) {
const candidates =
process.platform === "win32"
? hasExtension
? [path.join(dir, command)]
: exts.map((ext) => path.join(dir, `${command}${ext}`))
: [path.join(dir, command)];
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
}
return null;
}
function quoteForCmd(arg: string) {
if (!arg.length) return '""';
const escaped = arg.replace(/"/g, '""');
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
async function resolveSpawnTarget(
command: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
): Promise<SpawnTarget> {
const resolved = await resolveCommandPath(command, cwd, env);
const executable = resolved ?? command;
if (process.platform !== "win32") {
return { command: executable, args };
}
if (/\.(cmd|bat)$/i.test(executable)) {
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
return {
command: shell,
args: ["/d", "/s", "/c", commandLine],
};
}
return { command: executable, args };
}
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
if (typeof env.Path === "string" && env.Path.length > 0) return env;
@@ -168,37 +262,143 @@ export async function ensureAbsoluteDirectory(
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
try {
await fs.access(absolute, fsConstants.X_OK);
} catch {
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
return;
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;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const windowsExt = process.platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
: [""];
return null;
}
for (const dir of dirs) {
for (const ext of windowsExt) {
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
try {
await fs.access(candidate, fsConstants.X_OK);
return;
} catch {
// continue scanning PATH
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;
if (command.includes("/") || command.includes("\\")) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
throw new Error(`Command not found in PATH: "${command}"`);
}
@@ -219,79 +419,100 @@ export async function runChildProcess(
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
const child = spawn(command, args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
// don't refuse to start with "cannot be launched inside another session".
// These vars leak in when the Paperclip server itself is started from
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
// owned by Claude Code) or when cron inherits a contaminated shell env.
const CLAUDE_CODE_NESTING_VARS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION",
"CLAUDE_CODE_PARENT_SESSION",
] as const;
for (const key of CLAUDE_CODE_NESTING_VARS) {
delete rawMerged[key];
}
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
const mergedEnv = ensurePathInEnv(rawMerged);
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
.then((target) => {
const child = spawn(target.command, target.args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
}
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
errno === "ENOENT"
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
reject(new Error(msg));
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
});
});
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
errno === "ENOENT"
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
reject(new Error(msg));
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
});
});
});
})
.catch(reject);
});
}

View File

@@ -32,6 +32,27 @@ export interface UsageSummary {
export type AdapterBillingType = "api" | "subscription" | "unknown";
export interface AdapterRuntimeServiceReport {
id?: string | null;
projectId?: string | null;
projectWorkspaceId?: string | null;
issueId?: string | null;
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId?: string | null;
serviceName: string;
status?: "starting" | "running" | "stopped" | "failed";
lifecycle?: "shared" | "ephemeral";
reuseKey?: string | null;
command?: string | null;
cwd?: string | null;
port?: number | null;
url?: string | null;
providerRef?: string | null;
ownerAgentId?: string | null;
stopPolicy?: Record<string, unknown> | null;
healthStatus?: "unknown" | "healthy" | "unhealthy";
}
export interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
@@ -51,8 +72,17 @@ export interface AdapterExecutionResult {
billingType?: AdapterBillingType | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
question?: {
prompt: string;
choices: Array<{
key: string;
label: string;
description?: string;
}>;
} | null;
}
export interface AdapterSessionCodec {
@@ -167,7 +197,7 @@ export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
| { kind: "init"; ts: string; model: string; sessionId: string }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
@@ -208,6 +238,12 @@ export interface CreateConfigValues {
envBindings: Record<string, unknown>;
url: string;
bootstrapPrompt: string;
payloadTemplateJson?: string;
workspaceStrategyType?: string;
workspaceBaseRef?: string;
workspaceBranchTemplate?: string;
worktreeParentDir?: string;
runtimeServicesJson?: string;
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-claude-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-claude-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -3,6 +3,8 @@ export const label = "Claude Code (local)";
export const models = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];
@@ -23,8 +25,13 @@ Core fields:
- command (string, optional): defaults to "claude"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;

View File

@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (Object.keys(env).length > 0) ac.env = env;
ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
kind: "tool_call",
ts,
name: typeof block.name === "string" ? block.name : "unknown",
toolUseId:
typeof block.id === "string"
? block.id
: typeof block.tool_use_id === "string"
? block.tool_use_id
: undefined,
input: block.input ?? {},
});
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-codex-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-codex-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -31,6 +31,8 @@ Core fields:
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
@@ -40,4 +42,5 @@ Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@@ -13,17 +13,16 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.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;
@@ -67,33 +66,42 @@ function codexHomeDir(): string {
return path.join(os.homedir(), ".codex");
}
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 EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
linkSkill?: (source: string, target: string) => Promise<void>;
};
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
export async function ensureCodexSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCodexSkillsInjectedOptions = {},
) {
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = path.join(codexHomeDir(), "skills");
const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "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 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(
@@ -126,14 +134,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -192,6 +214,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
@@ -201,9 +226,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}

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

@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
typeof v.dangerouslyBypassSandbox === "boolean"
? v.dangerouslyBypassSandbox
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -1,4 +1,8 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import {
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
type TranscriptEntry,
} from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "string") return redactHomePathUserSegments(value);
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
} catch {
return String(value);
return redactHomePathUserSegments(String(value));
}
}
@@ -57,22 +61,24 @@ function parseCommandExecutionItem(
const command = asString(item.command);
const status = asString(item.status);
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
const output = asString(item.aggregated_output).replace(/\s+$/, "");
const safeCommand = redactHomePathUserSegments(command);
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
if (phase === "started") {
return [{
kind: "tool_call",
ts,
name: "command_execution",
toolUseId: id || command || "command_execution",
input: {
id,
command,
command: safeCommand,
},
}];
}
const lines: string[] = [];
if (command) lines.push(`command: ${command}`);
if (safeCommand) lines.push(`command: ${safeCommand}`);
if (status) lines.push(`status: ${status}`);
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
if (output) {
@@ -103,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
.filter((change): change is Record<string, unknown> => Boolean(change))
.map((change) => {
const kind = asString(change.kind, "update");
const path = asString(change.path, "unknown");
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
return `${kind} ${path}`;
});
@@ -125,13 +131,13 @@ function parseCodexItem(
if (itemType === "agent_message") {
const text = asString(item.text);
if (text) return [{ kind: "assistant", ts, text }];
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
return [];
}
if (itemType === "reasoning") {
const text = asString(item.text);
if (text) return [{ kind: "thinking", ts, text }];
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
}
@@ -147,8 +153,9 @@ function parseCodexItem(
return [{
kind: "tool_call",
ts,
name: asString(item.name, "unknown"),
input: item.input ?? {},
name: redactHomePathUserSegments(asString(item.name, "unknown")),
toolUseId: asString(item.id),
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
}];
}
@@ -160,24 +167,28 @@ function parseCodexItem(
asString(item.result) ||
stringifyUnknown(item.content ?? item.output ?? item.result);
const isError = item.is_error === true || asString(item.status) === "error";
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
}
if (itemType === "error" && phase === "completed") {
const text = errorText(item.message ?? item.error ?? item);
return [{ kind: "stderr", ts, text: text || "error" }];
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
}
const id = asString(item.id);
const status = asString(item.status);
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
return [{
kind: "system",
ts,
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
}];
}
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
}
const type = asString(parsed.type);
@@ -187,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "init",
ts,
model: asString(parsed.model, "codex"),
sessionId: threadId,
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
sessionId: redactHomePathUserSegments(threadId),
}];
}
@@ -210,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "result",
ts,
text: asString(parsed.result),
text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype),
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
isError: parsed.is_error === true,
errors: Array.isArray(parsed.errors)
? parsed.errors.map(errorText).filter(Boolean)
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
: [],
}];
}
@@ -232,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "result",
ts,
text: asString(parsed.result),
text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype, "turn.failed"),
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
isError: true,
errors: message ? [message] : [],
errors: message ? [redactHomePathUserSegments(message)] : [],
}];
}
if (type === "error") {
const message = errorText(parsed.message ?? parsed.error ?? parsed);
return [{ kind: "stderr", ts, text: message || line }];
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-cursor-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-cursor-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

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,7 +12,10 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -23,10 +25,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 +80,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 +91,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 +109,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(

View File

@@ -142,6 +142,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry
kind: "tool_call",
ts,
name,
toolUseId:
asString(part.tool_use_id) ||
asString(part.toolUseId) ||
asString(part.call_id) ||
asString(part.id) ||
undefined,
input,
});
continue;
@@ -199,6 +205,7 @@ function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): T
kind: "tool_call",
ts,
name: toolName,
toolUseId: callId,
input,
}];
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",

View File

@@ -0,0 +1,51 @@
{
"name": "@paperclipai/adapter-gemini-local",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,208 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
if (text) console.log(colorize(`${prefix}: ${text}`));
return;
}
const message = asRecord(messageRaw);
if (!message) return;
const directText = asString(message.text).trim();
if (directText) console.log(colorize(`${prefix}: ${directText}`));
const content = Array.isArray(message.content) ? message.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type === "output_text" || type === "text" || type === "content") {
const text = asString(part.text).trim() || asString(part.content).trim();
if (text) console.log(colorize(`${prefix}: ${text}`));
continue;
}
if (type === "thinking") {
const text = asString(part.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
continue;
}
if (type === "tool_call") {
const name = asString(part.name, asString(part.tool, "tool"));
console.log(pc.yellow(`tool_call: ${name}`));
const input = part.input ?? part.arguments ?? part.args;
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
continue;
}
if (type === "tool_result" || type === "tool_response") {
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
const contentText =
asString(part.output) ||
asString(part.text) ||
asString(part.result) ||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
}
}
}
function printUsage(parsed: Record<string, unknown>) {
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
const usageMetadata = asRecord(usage?.usageMetadata);
const source = usageMetadata ?? usage ?? {};
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
const cached = asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
);
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
}
export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {
const sessionId =
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID) ||
asString(parsed.checkpoint_id);
const model = asString(parsed.model);
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
.filter(Boolean)
.join(", ");
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
return;
}
if (subtype === "error") {
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
if (text) console.log(pc.red(`error: ${text}`));
return;
}
console.log(pc.blue(`system: ${subtype || "event"}`));
return;
}
if (type === "assistant") {
printTextMessage("assistant", pc.green, parsed.message);
return;
}
if (type === "user") {
printTextMessage("user", pc.gray, parsed.message);
return;
}
if (type === "thinking") {
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "tool_call") {
const subtype = asString(parsed.subtype).trim().toLowerCase();
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
const [toolName] = toolCall ? Object.keys(toolCall) : [];
if (!toolCall || !toolName) {
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
return;
}
const payload = asRecord(toolCall[toolName]) ?? {};
if (subtype === "started" || subtype === "start") {
console.log(pc.yellow(`tool_call: ${toolName}`));
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
return;
}
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
const isError =
parsed.is_error === true ||
payload.is_error === true ||
payload.error !== undefined ||
asString(payload.status).toLowerCase() === "error";
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
return;
}
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
return;
}
if (type === "result") {
printUsage(parsed);
const subtype = asString(parsed.subtype, "result");
const isError = parsed.is_error === true;
if (subtype || isError) {
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
}
return;
}
if (type === "error") {
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
if (text) console.log(pc.red(`error: ${text}`));
return;
}
console.log(line);
}

View File

@@ -0,0 +1 @@
export { printGeminiStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,47 @@
export const type = "gemini_local";
export const label = "Gemini CLI (local)";
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
];
export const agentConfigurationDoc = `# gemini_local agent configuration
Adapter: gemini_local
Use when:
- You want Paperclip to run the Gemini CLI locally on the host machine
- You want Gemini chat sessions resumed across heartbeats with --resume
- You want Paperclip skills injected locally without polluting the global environment
Don't use when:
- You need webhook-style external invocation (use http or openclaw_gateway)
- You only need a one-shot script without an AI coding agent loop (use process)
- Gemini CLI is not installed on the machine that runs Paperclip
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Gemini model id. Defaults to auto.
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
- command (string, optional): defaults to "gemini"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Runs use positional prompt arguments, not stdin.
- Sessions resume with --resume when stored session cwd matches the current cwd.
- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
`;

View File

@@ -0,0 +1,426 @@
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";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asBoolean,
asNumber,
asString,
asStringArray,
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import {
describeGeminiFailure,
detectGeminiAuthRequired,
isGeminiTurnLimitResult,
isGeminiUnknownSessionError,
parseGeminiJsonl,
} from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api"
: "subscription";
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (paperclipKeys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
return [
"Paperclip API access note:",
"Use run_shell_command with curl to make Paperclip API requests.",
"GET example:",
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
"POST/PATCH example:",
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
"",
"",
].join("\n");
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
/**
* Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
* both its auth credentials and the injected skills in the real home directory.
*/
async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = geminiSkillsHome();
try {
await fs.mkdir(skillsHome, { recursive: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`,
);
}
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
try {
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",
`[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const command = asString(config.command, "gemini");
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const sandbox = asBoolean(config.sandbox, false);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureGeminiSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveGeminiBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
);
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
args.push("--approval-mode", "yolo");
if (sandbox) {
args.push("--sandbox");
} else {
args.push("--sandbox=none");
}
if (extraArgs.length > 0) args.push(...extraArgs);
args.push(prompt);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "gemini_local",
command,
cwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: redactEnvForLogs(env),
prompt,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
return {
proc,
parsed: parseGeminiJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
parsed: ReturnType<typeof parseGeminiJsonl>;
},
clearSessionOnMissingSession = false,
isRetry = false,
): AdapterExecutionResult => {
const authMeta = detectGeminiAuthRequired({
parsed: attempt.parsed.resultEvent,
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
});
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
clearSession: clearSessionOnMissingSession,
};
}
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
// On retry, don't fall back to old session ID — the old session was stale
const canFallbackToRuntimeSession = !isRetry;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const structuredFailure = attempt.parsed.resultEvent
? describeGeminiFailure(attempt.parsed.resultEvent)
: null;
const fallbackErrorMessage =
parsedError ||
structuredFailure ||
stderrLine ||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "google",
model,
billingType,
costUsd: attempt.parsed.costUsd,
resultJson: attempt.parsed.resultEvent ?? {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
question: attempt.parsed.question,
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stderr",
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true, true);
}
return toResult(initial);
}

View File

@@ -0,0 +1,70 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
parseGeminiJsonl,
isGeminiUnknownSessionError,
describeGeminiFailure,
detectGeminiAuthRequired,
isGeminiTurnLimitResult,
} from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};

View File

@@ -0,0 +1,263 @@
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
function collectMessageText(message: unknown): string[] {
if (typeof message === "string") {
const trimmed = message.trim();
return trimmed ? [trimmed] : [];
}
const record = parseObject(message);
const direct = asString(record.text, "").trim();
const lines: string[] = direct ? [direct] : [];
const content = Array.isArray(record.content) ? record.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
const type = asString(part.type, "").trim();
if (type === "output_text" || type === "text" || type === "content") {
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
if (text) lines.push(text);
}
}
return lines;
}
function readSessionId(event: Record<string, unknown>): string | null {
return (
asString(event.session_id, "").trim() ||
asString(event.sessionId, "").trim() ||
asString(event.sessionID, "").trim() ||
asString(event.checkpoint_id, "").trim() ||
asString(event.thread_id, "").trim() ||
null
);
}
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message =
asString(rec.message, "") ||
asString(rec.error, "") ||
asString(rec.code, "") ||
asString(rec.detail, "");
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function accumulateUsage(
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
usageRaw: unknown,
) {
const usage = parseObject(usageRaw);
const usageMetadata = parseObject(usage.usageMetadata);
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
target.inputTokens += asNumber(
source.input_tokens,
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
);
target.cachedInputTokens += asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
);
target.outputTokens += asNumber(
source.output_tokens,
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
);
}
export function parseGeminiJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let costUsd: number | null = null;
let resultEvent: Record<string, unknown> | null = null;
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
};
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const foundSessionId = readSessionId(event);
if (foundSessionId) sessionId = foundSessionId;
const type = asString(event.type, "").trim();
if (type === "assistant") {
messages.push(...collectMessageText(event.message));
const messageObj = parseObject(event.message);
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
if (asString(part.type, "").trim() === "question") {
question = {
prompt: asString(part.prompt, "").trim(),
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
const choice = parseObject(choiceRaw);
return {
key: asString(choice.key, "").trim(),
label: asString(choice.label, "").trim(),
description: asString(choice.description, "").trim() || undefined,
};
}),
};
break; // only one question per message
}
}
continue;
}
if (type === "result") {
resultEvent = event;
accumulateUsage(usage, event.usage ?? event.usageMetadata);
const resultText =
asString(event.result, "").trim() ||
asString(event.text, "").trim() ||
asString(event.response, "").trim();
if (resultText && messages.length === 0) messages.push(resultText);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
if (isError) {
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
if (text) errorMessage = text;
}
continue;
}
if (type === "error") {
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
if (text) errorMessage = text;
continue;
}
if (type === "system") {
const subtype = asString(event.subtype, "").trim().toLowerCase();
if (subtype === "error") {
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
if (text) errorMessage = text;
}
continue;
}
if (type === "text") {
const part = parseObject(event.part);
const text = asString(part.text, "").trim();
if (text) messages.push(text);
continue;
}
if (type === "step_finish" || event.usage || event.usageMetadata) {
accumulateUsage(usage, event.usage ?? event.usageMetadata);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
continue;
}
}
return {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage,
resultEvent,
question,
};
}
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
haystack,
);
}
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
const messages: string[] = [];
const errorMsg = asString(parsed.error, "").trim();
if (errorMsg) messages.push(errorMsg);
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
for (const entry of raw) {
if (typeof entry === "string") {
const msg = entry.trim();
if (msg) messages.push(msg);
continue;
}
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
const obj = entry as Record<string, unknown>;
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
if (msg) {
messages.push(msg);
continue;
}
try {
messages.push(JSON.stringify(obj));
} catch {
// skip non-serializable entry
}
}
return messages;
}
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
const status = asString(parsed.status, "");
const errors = extractGeminiErrorMessages(parsed);
const detail = errors[0] ?? "";
const parts = ["Gemini run failed"];
if (status) parts.push(`status=${status}`);
if (detail) parts.push(detail);
return parts.length > 1 ? parts.join(": ") : null;
}
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
export function detectGeminiAuthRequired(input: {
parsed: Record<string, unknown> | null;
stdout: string;
stderr: string;
}): { requiresAuth: boolean } {
const errors = extractGeminiErrorMessages(input.parsed ?? {});
const messages = [...errors, input.stdout, input.stderr]
.join("\n")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
return { requiresAuth };
}
export function isGeminiTurnLimitResult(
parsed: Record<string, unknown> | null | undefined,
exitCode?: number | null,
): boolean {
if (exitCode === 53) return true;
if (!parsed) return false;
const status = asString(parsed.status, "").trim().toLowerCase();
if (status === "turn_limit" || status === "max_turns") return true;
const error = asString(parsed.error, "").trim();
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
}

View File

@@ -0,0 +1,223 @@
import path from "node:path";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asBoolean,
asString,
asStringArray,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
parseObject,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}` : clean;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "gemini");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "gemini_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "gemini_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "gemini_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "gemini_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const configGeminiApiKey = env.GEMINI_API_KEY;
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
const configGoogleApiKey = env.GOOGLE_API_KEY;
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
if (
isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) ||
isNonEmpty(configGoogleApiKey) ||
isNonEmpty(hostGoogleApiKey) ||
hasGca
) {
const source = hasGca
? "Google account login (GCA)"
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
? "adapter config env"
: "server environment";
checks.push({
code: "gemini_api_key_present",
level: "info",
message: "Gemini API credentials are set for CLI authentication.",
detail: `Detected in ${source}.`,
});
} else {
checks.push({
code: "gemini_api_key_missing",
level: "info",
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
});
}
const canRunProbe =
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
if (canRunProbe) {
if (!commandLooksLike(command, "gemini")) {
checks.push({
code: "gemini_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not `gemini`.",
detail: command,
hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
});
} else {
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["--output-format", "stream-json"];
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
if (sandbox) {
args.push("--sandbox");
} else {
args.push("--sandbox=none");
}
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
const probe = await runChildProcess(
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env,
timeoutSec: 45,
graceSec: 5,
onLog: async () => { },
},
);
const parsed = parseGeminiJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authMeta = detectGeminiAuthRequired({
parsed: parsed.resultEvent,
stdout: probe.stdout,
stderr: probe.stderr,
});
if (probe.timedOut) {
checks.push({
code: "gemini_hello_probe_timed_out",
level: "warn",
message: "Gemini hello probe timed out.",
hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
});
} else if ((probe.exitCode ?? 1) === 0) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Gemini hello probe succeeded."
: "Gemini probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
}),
});
} else if (authMeta.requiresAuth) {
checks.push({
code: "gemini_hello_probe_auth_required",
level: "warn",
message: "Gemini CLI is installed, but authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
});
} else {
checks.push({
code: "gemini_hello_probe_failed",
level: "error",
message: "Gemini hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
});
}
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,8 @@
export function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}

Some files were not shown because too many files have changed in this diff Show More