Compare commits

..

129 Commits

Author SHA1 Message Date
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
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
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
adamrobbie
8a7b7a2383 docs: remove obsolete TODO for CONTRIBUTING.md 2026-03-09 16:58:57 -04: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
Chris Schneider
f99f174e2d Show issue creator in properties sidebar 2026-03-06 17:16:39 +00:00
163 changed files with 9880 additions and 2198 deletions

View File

@@ -33,7 +33,7 @@ Use this skill when leadership asks for:
Before proceeding, verify all of the following:
1. `skills/release-changelog/SKILL.md` exists and is usable.
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.

View File

@@ -16,6 +16,7 @@ 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/

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

@@ -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:*",

View File

@@ -2,8 +2,16 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
rebindWorkspaceCwd,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
worktreeMakeCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -15,6 +23,20 @@ import {
} 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: {
@@ -78,6 +100,58 @@ describe("worktree helpers", () => {
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");
@@ -110,6 +184,7 @@ describe("worktree helpers", () => {
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'");
});
@@ -129,7 +204,11 @@ describe("worktree helpers", () => {
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");
@@ -148,6 +227,16 @@ describe("worktree helpers", () => {
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 });
}
});
@@ -173,6 +262,36 @@ describe("worktree helpers", () => {
}
});
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({
@@ -248,4 +367,39 @@ describe("worktree helpers", () => {
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

@@ -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

@@ -202,6 +202,7 @@ export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<strin
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
};
}

View File

@@ -62,6 +62,10 @@ type WorktreeInitOptions = {
force?: boolean;
};
type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint?: string;
};
type WorktreeEnvOptions = {
config?: string;
json?: boolean;
@@ -115,6 +119,82 @@ function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
const currentConfigPath = process.env.PAPERCLIP_CONFIG;
if (!currentConfigPath || currentConfigPath.trim().length === 0) {
return false;
}
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
}
const WORKTREE_NAME_PREFIX = "paperclip-";
function resolveWorktreeMakeName(name: string): string {
const value = nonEmpty(name);
if (!value) {
throw new Error("Worktree name is required.");
}
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
throw new Error(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
}
return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`;
}
function resolveWorktreeHome(explicit?: string): string {
return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME;
}
function resolveWorktreeStartPoint(explicit?: string): string | undefined {
return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined;
}
export function resolveWorktreeMakeTargetPath(name: string): string {
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
}
function extractExecSyncErrorMessage(error: unknown): string | null {
if (!error || typeof error !== "object") {
return error instanceof Error ? error.message : null;
}
const stderr = "stderr" in error ? error.stderr : null;
if (typeof stderr === "string") {
return nonEmpty(stderr);
}
if (stderr instanceof Buffer) {
return nonEmpty(stderr.toString("utf8"));
}
return error instanceof Error ? nonEmpty(error.message) : null;
}
function localBranchExists(cwd: string, branchName: string): boolean {
try {
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
export function resolveGitWorktreeAddArgs(input: {
branchName: string;
targetPath: string;
branchExists: boolean;
startPoint?: string;
}): string[] {
if (input.branchExists && !input.startPoint) {
return ["worktree", "add", input.targetPath, input.branchName];
}
const commitish = input.startPoint ?? "HEAD";
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
@@ -378,9 +458,10 @@ export function copySeededSecretsKey(input: {
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath);
const sourceInlineMasterKey =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null);
if (sourceInlineMasterKey) {
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
encoding: "utf8",
@@ -396,7 +477,7 @@ export function copySeededSecretsKey(input: {
const sourceKeyFileOverride =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null);
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
@@ -443,6 +524,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
@@ -538,10 +620,7 @@ async function seedWorktreeDatabase(input: {
}
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
cwd,
@@ -554,7 +633,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const paths = resolveWorktreeLocalPaths({
cwd,
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
homeDir: resolveWorktreeHome(opts.home),
instanceId,
});
const sourceConfigPath = resolveSourceConfigPath(opts);
@@ -583,7 +662,17 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
});
writeConfig(targetConfig, paths.configPath);
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
const existingAgentJwtSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
mergePaperclipEnvEntries(
{
...buildWorktreeEnvEntries(paths),
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
},
paths.envPath,
);
ensureAgentJwtSecret(paths.configPath);
loadPaperclipEnvFile(paths.configPath);
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
@@ -642,6 +731,312 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
);
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
await runWorktreeInit(opts);
}
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
const name = resolveWorktreeMakeName(nameArg);
const startPoint = resolveWorktreeStartPoint(opts.startPoint);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists: ${targetPath}`);
}
mkdirSync(path.dirname(targetPath), { recursive: true });
if (startPoint) {
const [remote] = startPoint.split("/", 1);
try {
execFileSync("git", ["fetch", remote], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
);
}
}
const worktreeArgs = resolveGitWorktreeAddArgs({
branchName: name,
targetPath,
branchExists: !startPoint && localBranchExists(sourceCwd, name),
startPoint,
});
const spinner = p.spinner();
spinner.start(`Creating git worktree at ${targetPath}...`);
try {
execFileSync("git", worktreeArgs, {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Created git worktree at ${targetPath}.`);
} catch (error) {
spinner.stop(pc.red("Failed to create git worktree."));
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
const originalCwd = process.cwd();
try {
process.chdir(targetPath);
await runWorktreeInit({
...opts,
name,
});
} catch (error) {
throw error;
} finally {
process.chdir(originalCwd);
}
}
type WorktreeCleanupOptions = {
instance?: string;
home?: string;
force?: boolean;
};
type GitWorktreeListEntry = {
worktree: string;
branch: string | null;
bare: boolean;
detached: boolean;
};
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const entries: GitWorktreeListEntry[] = [];
let current: Partial<GitWorktreeListEntry> = {};
for (const line of raw.split("\n")) {
if (line.startsWith("worktree ")) {
current = { worktree: line.slice("worktree ".length) };
} else if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length);
} else if (line === "bare") {
current.bare = true;
} else if (line === "detached") {
current.detached = true;
} else if (line === "" && current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
bare: current.bare ?? false,
detached: current.detached ?? false,
});
current = {};
}
}
if (current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
bare: current.bare ?? false,
detached: current.detached ?? false,
});
}
return entries;
}
function branchHasUniqueCommits(cwd: string, branchName: string): boolean {
try {
const output = execFileSync(
"git",
["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"],
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean {
try {
const output = execFileSync(
"git",
["branch", "-r", "--list", `*/${branchName}`],
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
function worktreePathHasUncommittedChanges(worktreePath: string): boolean {
try {
const output = execFileSync(
"git",
["status", "--porcelain"],
{ cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
return output.length > 0;
} catch {
return false;
}
}
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
const name = resolveWorktreeMakeName(nameArg);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home)));
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
// ── 1. Assess current state ──────────────────────────────────────────
const hasBranch = localBranchExists(sourceCwd, name);
const hasTargetDir = existsSync(targetPath);
const hasInstanceData = existsSync(instanceRoot);
const worktrees = parseGitWorktreeList(sourceCwd);
const linkedWorktree = worktrees.find(
(wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath),
);
if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) {
p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found.");
p.outro(pc.green("Already clean."));
return;
}
// ── 2. Safety checks ────────────────────────────────────────────────
const problems: string[] = [];
if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) {
const onRemote = branchExistsOnAnyRemote(sourceCwd, name);
if (onRemote) {
p.log.info(
`Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`,
);
} else {
problems.push(
`Branch "${name}" has commits not found on any other branch or remote. ` +
`Deleting it will lose work. Push it first, or use --force.`,
);
}
}
if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) {
problems.push(
`Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`,
);
}
if (problems.length > 0 && !opts.force) {
for (const problem of problems) {
p.log.error(problem);
}
throw new Error("Safety checks failed. Resolve the issues above or re-run with --force.");
}
if (problems.length > 0 && opts.force) {
for (const problem of problems) {
p.log.warning(`Overridden by --force: ${problem}`);
}
}
// ── 3. Clean up (idempotent steps) ──────────────────────────────────
// 3a. Remove the git worktree registration
if (linkedWorktree) {
const worktreeDirExists = existsSync(linkedWorktree.worktree);
const spinner = p.spinner();
if (worktreeDirExists) {
spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`);
try {
const removeArgs = ["worktree", "remove", linkedWorktree.worktree];
if (opts.force) removeArgs.push("--force");
execFileSync("git", removeArgs, {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`);
} catch (error) {
spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
} else {
spinner.start("Pruning stale worktree entry...");
execFileSync("git", ["worktree", "prune"], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop("Pruned stale worktree entry.");
}
} else {
// Even without a linked worktree, prune to clean up any orphaned entries
execFileSync("git", ["worktree", "prune"], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
}
// 3b. Remove the worktree directory if it still exists (e.g. partial creation)
if (existsSync(targetPath)) {
const spinner = p.spinner();
spinner.start(`Removing worktree directory ${targetPath}...`);
rmSync(targetPath, { recursive: true, force: true });
spinner.stop(`Removed worktree directory ${targetPath}.`);
}
// 3c. Delete the local branch (now safe — worktree is gone)
if (localBranchExists(sourceCwd, name)) {
const spinner = p.spinner();
spinner.start(`Deleting local branch "${name}"...`);
try {
const deleteFlag = opts.force ? "-D" : "-d";
execFileSync("git", ["branch", deleteFlag, name], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Deleted local branch "${name}".`);
} catch (error) {
spinner.stop(pc.yellow(`Could not delete branch "${name}".`));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
}
// 3d. Remove instance data
if (existsSync(instanceRoot)) {
const spinner = p.spinner();
spinner.start(`Removing instance data at ${instanceRoot}...`);
rmSync(instanceRoot, { recursive: true, force: true });
spinner.stop(`Removed instance data at ${instanceRoot}.`);
}
p.outro(pc.green("Cleanup complete."));
}
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const envPath = resolvePaperclipEnvFile(configPath);
@@ -665,12 +1060,29 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
program
.command("worktree:make")
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
worktree
.command("init")
.description("Create repo-local config/env and an isolated instance for this worktree")
.option("--name <name>", "Display name used to derive the instance id")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
@@ -687,4 +1099,13 @@ export function registerWorktreeCommands(program: Command): void {
.option("-c, --config <path>", "Path to config file")
.option("--json", "Print JSON instead of shell exports")
.action(worktreeEnvCommand);
program
.command("worktree:cleanup")
.description("Safely remove a worktree, its branch, and its isolated instance data")
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed")
.option("--instance <id>", "Explicit instance id (if different from the worktree name)")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false)
.action(worktreeCleanupCommand);
}

View File

@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
auth,
};
}

View File

@@ -132,6 +132,8 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
```sh
paperclipai worktree init
# or create the git worktree and initialize it in one step:
pnpm paperclipai worktree:make paperclip-pr-432
```
This command:
@@ -150,6 +152,8 @@ Seed modes:
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
Print shell exports explicitly when needed:
```sh
@@ -158,17 +162,73 @@ paperclipai worktree env
eval "$(paperclipai worktree env)"
```
Useful options:
### 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 minimal
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

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

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

View File

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

@@ -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

@@ -22,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

@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
question?: {
prompt: string;
choices: Array<{
key: string;
label: string;
description?: string;
}>;
} | null;
}
export interface AdapterSessionCodec {
@@ -189,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[] }

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,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

@@ -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

@@ -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,436 @@
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,
ensurePathInEnv,
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));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
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");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
/**
* 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 skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) 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;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
} 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) ?? ""
);
}

View File

@@ -0,0 +1,75 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
ac.sandbox = !v.dangerouslyBypassSandbox;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parseGeminiStdoutLine } from "./parse-stdout.js";
export { buildGeminiLocalConfig } from "./build-config.js";

View File

@@ -0,0 +1,274 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
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 collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
return text ? [{ kind, ts, text }] : [];
}
const message = asRecord(messageRaw);
if (!message) return [];
const entries: TranscriptEntry[] = [];
const directText = asString(message.text).trim();
if (directText) entries.push({ kind, ts, text: 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") continue;
const text = asString(part.text).trim() || asString(part.content).trim();
if (text) entries.push({ kind, ts, text });
}
return entries;
}
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
return text ? [{ kind: "assistant", ts, text }] : [];
}
const message = asRecord(messageRaw);
if (!message) return [];
const entries: TranscriptEntry[] = [];
const directText = asString(message.text).trim();
if (directText) entries.push({ kind: "assistant", ts, text: 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) entries.push({ kind: "assistant", ts, text });
continue;
}
if (type === "thinking") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "thinking", ts, text });
continue;
}
if (type === "tool_call") {
const name = asString(part.name, asString(part.tool, "tool"));
entries.push({
kind: "tool_call",
ts,
name,
input: part.input ?? part.arguments ?? part.args ?? {},
});
continue;
}
if (type === "tool_result" || type === "tool_response") {
const toolUseId =
asString(part.tool_use_id) ||
asString(part.toolUseId) ||
asString(part.call_id) ||
asString(part.id) ||
"tool_result";
const contentText =
asString(part.output) ||
asString(part.text) ||
asString(part.result) ||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: contentText,
isError,
});
}
}
return entries;
}
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
const subtype = asString(parsed.subtype).trim().toLowerCase();
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
if (!toolCall) {
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
}
const [toolName] = Object.keys(toolCall);
if (!toolName) {
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
}
const payload = asRecord(toolCall[toolName]) ?? {};
if (subtype === "started" || subtype === "start") {
return [{
kind: "tool_call",
ts,
name: toolName,
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
}];
}
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
const result = payload.result ?? payload.output ?? payload.error;
const isError =
parsed.is_error === true ||
payload.is_error === true ||
payload.error !== undefined ||
asString(payload.status).toLowerCase() === "error";
return [{
kind: "tool_result",
ts,
toolUseId: callId,
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
isError,
}];
}
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
}
function readSessionId(parsed: Record<string, unknown>): string {
return (
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID) ||
asString(parsed.checkpoint_id) ||
asString(parsed.thread_id)
);
}
function readUsage(parsed: Record<string, unknown>) {
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
const usageMetadata = asRecord(usage?.usageMetadata);
const source = usageMetadata ?? usage ?? {};
return {
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
cachedTokens: asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
),
};
}
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type);
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {
const sessionId = readSessionId(parsed);
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
}
if (subtype === "error") {
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
return [{ kind: "stderr", ts, text: text || "error" }];
}
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
}
if (type === "assistant") {
return parseAssistantMessage(parsed.message, ts);
}
if (type === "user") {
return collectTextEntries(parsed.message, ts, "user");
}
if (type === "thinking") {
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
return text ? [{ kind: "thinking", ts, text }] : [];
}
if (type === "tool_call") {
return parseTopLevelToolEvent(parsed, ts);
}
if (type === "result") {
const usage = readUsage(parsed);
const errors = parsed.is_error === true
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
: [];
return [{
kind: "result",
ts,
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedTokens: usage.cachedTokens,
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
subtype: asString(parsed.subtype, "result"),
isError: parsed.is_error === true,
errors,
}];
}
if (type === "error") {
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
return [{ kind: "stderr", ts, text: text || "error" }];
}
return [{ kind: "stdout", ts, text: line }];
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -1069,7 +1069,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const agentParams: Record<string, unknown> = {
...payloadTemplate,
paperclip: paperclipPayload,
message,
sessionKey,
idempotencyKey: ctx.runId,

View File

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

View File

@@ -50,6 +50,7 @@ function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEn
kind: "tool_call",
ts,
name: toolName,
toolUseId: asString(part.callID) || asString(part.id) || undefined,
input,
};

View File

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

View File

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

View File

@@ -46,6 +46,7 @@ describe("resolveDatabaseTarget", () => {
const projectDir = path.join(tempDir, "repo");
fs.mkdirSync(projectDir, { recursive: true });
process.chdir(projectDir);
delete process.env.PAPERCLIP_CONFIG;
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
});

View File

@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
"http",
"claude_local",
"codex_local",
"gemini_local",
"opencode_local",
"pi_local",
"cursor",

View File

@@ -99,6 +99,7 @@ export type {
AgentRuntimeState,
AgentTaskSession,
AgentWakeupRequest,
InstanceSchedulerHeartbeatAgent,
LiveEvent,
DashboardSummary,
ActivityEvent,

View File

@@ -18,5 +18,4 @@ export interface DashboardSummary {
monthUtilizationPercent: number;
};
pendingApprovals: number;
staleTasks: number;
}

View File

@@ -1,4 +1,6 @@
import type {
AgentRole,
AgentStatus,
HeartbeatInvocationSource,
HeartbeatRunStatus,
WakeupTriggerDetail,
@@ -105,3 +107,20 @@ export interface AgentWakeupRequest {
createdAt: Date;
updatedAt: Date;
}
export interface InstanceSchedulerHeartbeatAgent {
id: string;
companyId: string;
companyName: string;
companyIssuePrefix: string;
agentName: string;
agentUrlKey: string;
role: AgentRole;
title: string | null;
status: AgentStatus;
adapterType: string;
intervalSec: number;
heartbeatEnabled: boolean;
schedulerActive: boolean;
lastHeartbeatAt: Date | null;
}

View File

@@ -48,6 +48,7 @@ export type {
AgentRuntimeState,
AgentTaskSession,
AgentWakeupRequest,
InstanceSchedulerHeartbeatAgent,
} from "./heartbeat.js";
export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";

49
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
cross-env:
specifier: ^10.1.0
version: 10.1.0
esbuild:
specifier: ^0.27.3
version: 0.27.3
@@ -38,6 +41,9 @@ importers:
'@paperclipai/adapter-cursor-local':
specifier: workspace:*
version: link:../packages/adapters/cursor-local
'@paperclipai/adapter-gemini-local':
specifier: workspace:*
version: link:../packages/adapters/gemini-local
'@paperclipai/adapter-openclaw-gateway':
specifier: workspace:*
version: link:../packages/adapters/openclaw-gateway
@@ -68,6 +74,9 @@ importers:
drizzle-orm:
specifier: 0.38.4
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -139,6 +148,22 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/adapters/gemini-local:
dependencies:
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapters/openclaw-gateway:
dependencies:
'@paperclipai/adapter-utils':
@@ -245,6 +270,9 @@ importers:
'@paperclipai/adapter-cursor-local':
specifier: workspace:*
version: link:../packages/adapters/cursor-local
'@paperclipai/adapter-gemini-local':
specifier: workspace:*
version: link:../packages/adapters/gemini-local
'@paperclipai/adapter-openclaw-gateway':
specifier: workspace:*
version: link:../packages/adapters/openclaw-gateway
@@ -321,6 +349,9 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
supertest:
specifier: ^7.0.0
version: 7.2.2
@@ -360,6 +391,9 @@ importers:
'@paperclipai/adapter-cursor-local':
specifier: workspace:*
version: link:../packages/adapters/cursor-local
'@paperclipai/adapter-gemini-local':
specifier: workspace:*
version: link:../packages/adapters/gemini-local
'@paperclipai/adapter-openclaw-gateway':
specifier: workspace:*
version: link:../packages/adapters/openclaw-gateway
@@ -989,6 +1023,9 @@ packages:
cpu: [x64]
os: [win32]
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
@@ -3424,6 +3461,11 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
hasBin: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -6741,6 +6783,8 @@ snapshots:
'@embedded-postgres/windows-x64@18.1.0-beta.16':
optional: true
'@epic-web/invariant@1.0.0': {}
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -9255,6 +9299,11 @@ snapshots:
crelt@1.0.6: {}
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1

View File

@@ -7,61 +7,109 @@
* working tree (not just staged changes).
*
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
* If the file is missing, the check passes silently — other developers
* on the project won't have this list, and that's fine.
* If the file is missing, the check still uses the active local username when
* available. If username detection fails, the check degrades gracefully.
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt");
if (!existsSync(tokensFile)) {
console.log(" Forbidden tokens list not found — skipping check.");
process.exit(0);
function uniqueNonEmpty(values) {
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
}
const tokens = readFileSync(tokensFile, "utf8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
const candidates = [env.USER, env.LOGNAME, env.USERNAME];
if (tokens.length === 0) {
console.log(" Forbidden tokens list is empty — skipping check.");
process.exit(0);
}
// Use git grep to search tracked files only (avoids node_modules, dist, etc.)
let found = false;
for (const token of tokens) {
try {
const result = execSync(
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
);
if (result.trim()) {
if (!found) {
console.error("ERROR: Forbidden tokens found in tracked files:\n");
}
found = true;
// Print matches but DO NOT print which token was matched (avoids leaking the list)
const lines = result.trim().split("\n");
for (const line of lines) {
console.error(` ${line}`);
}
}
candidates.push(osModule.userInfo().username);
} catch {
// git grep returns exit code 1 when no matches — that's fine
// Some environments do not expose userInfo; env vars are enough fallback.
}
return uniqueNonEmpty(candidates);
}
if (found) {
console.error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
process.exit(1);
} else {
console.log(" ✓ No forbidden tokens found.");
export function readForbiddenTokensFile(tokensFile) {
if (!existsSync(tokensFile)) return [];
return readFileSync(tokensFile, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) {
return uniqueNonEmpty([
...resolveDynamicForbiddenTokens(env, osModule),
...readForbiddenTokensFile(tokensFile),
]);
}
export function runForbiddenTokenCheck({
repoRoot,
tokens,
exec = execSync,
log = console.log,
error = console.error,
}) {
if (tokens.length === 0) {
log(" Forbidden tokens list is empty — skipping check.");
return 0;
}
let found = false;
for (const token of tokens) {
try {
const result = exec(
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
);
if (result.trim()) {
if (!found) {
error("ERROR: Forbidden tokens found in tracked files:\n");
}
found = true;
const lines = result.trim().split("\n");
for (const line of lines) {
error(` ${line}`);
}
}
} catch {
// git grep returns exit code 1 when no matches — that's fine
}
}
if (found) {
error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
return 1;
}
log(" ✓ No forbidden tokens found.");
return 0;
}
function resolveRepoPaths(exec = execSync) {
const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
return {
repoRoot,
tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"),
};
}
function main() {
const { repoRoot, tokensFile } = resolveRepoPaths();
const tokens = resolveForbiddenTokens(tokensFile);
process.exit(runForbiddenTokenCheck({ repoRoot, tokens }));
}
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
main();
}

View File

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

View File

@@ -0,0 +1,70 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { activityRoutes } from "../routes/activity.js";
const mockActivityService = vi.hoisted(() => ({
list: vi.fn(),
forIssue: vi.fn(),
runsForIssue: vi.fn(),
issuesForRun: vi.fn(),
create: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
vi.mock("../services/index.js", () => ({
issueService: () => mockIssueService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", activityRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("activity routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves issue identifiers before loading runs", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-uuid-1",
companyId: "company-1",
});
mockActivityService.runsForIssue.mockResolvedValue([
{
runId: "run-1",
},
]);
const res = await request(createApp()).get("/api/issues/PAP-475/runs");
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
expect(mockIssueService.getById).not.toHaveBeenCalled();
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
expect(res.body).toEqual([{ runId: "run-1" }]);
});
});

View File

@@ -5,6 +5,10 @@ import {
sessionCodec as cursorSessionCodec,
isCursorUnknownSessionError,
} from "@paperclipai/adapter-cursor-local/server";
import {
sessionCodec as geminiSessionCodec,
isGeminiUnknownSessionError,
} from "@paperclipai/adapter-gemini-local/server";
import {
sessionCodec as opencodeSessionCodec,
isOpenCodeUnknownSessionError,
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
});
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
});
it("normalizes gemini session params with cwd", () => {
const parsed = geminiSessionCodec.deserialize({
session_id: "gemini-session-1",
cwd: "/tmp/gemini",
});
expect(parsed).toEqual({
sessionId: "gemini-session-1",
cwd: "/tmp/gemini",
});
const serialized = geminiSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "gemini-session-1",
cwd: "/tmp/gemini",
});
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
});
});
describe("codex resume recovery detection", () => {
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
).toBe(false);
});
});
describe("gemini resume recovery detection", () => {
it("detects unknown session errors from gemini output", () => {
expect(
isGeminiUnknownSessionError(
"",
"unknown session id abc",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
"",
"checkpoint latest not found",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
"{\"type\":\"result\",\"subtype\":\"success\"}",
"",
),
).toBe(false);
});
});

View File

@@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "command_execution",
toolUseId: "item_2",
input: { id: "item_2", command: "/bin/zsh -lc ls" },
},
]);
@@ -106,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
item: {
id: "item_52",
type: "file_change",
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
status: "completed",
},
}),
@@ -116,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View File

@@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
kind: "tool_call",
ts,
name: "shellToolCall",
toolUseId: "call_shell_1",
input: { command: longCommand },
},
]);
@@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
}),
ts,
),
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]);
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
expect(
parseCursorStdoutLine(

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
const {
resolveDynamicForbiddenTokens,
resolveForbiddenTokens,
runForbiddenTokenCheck,
} = await import("../../../scripts/check-forbidden-tokens.mjs");
describe("forbidden token check", () => {
it("derives username tokens without relying on whoami", () => {
const tokens = resolveDynamicForbiddenTokens(
{ USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" },
{
userInfo: () => ({ username: "paperclip" }),
},
);
expect(tokens).toEqual(["paperclip", "pc"]);
});
it("falls back cleanly when user resolution fails", () => {
const tokens = resolveDynamicForbiddenTokens(
{},
{
userInfo: () => {
throw new Error("missing user");
},
},
);
expect(tokens).toEqual([]);
});
it("merges dynamic and file-based forbidden tokens", async () => {
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`);
fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n");
try {
const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, {
userInfo: () => ({ username: "paperclip" }),
});
expect(tokens).toEqual(["paperclip", "custom-token"]);
} finally {
fs.unlinkSync(tokensFile);
}
});
it("reports matches without leaking which token was searched", () => {
const exec = vi
.fn()
.mockReturnValueOnce("server/file.ts:1:found\n")
.mockImplementation(() => {
throw new Error("not found");
});
const log = vi.fn();
const error = vi.fn();
const exitCode = runForbiddenTokenCheck({
repoRoot: "/repo",
tokens: ["paperclip", "custom-token"],
exec,
log,
error,
});
expect(exitCode).toBe(1);
expect(exec).toHaveBeenCalledTimes(2);
expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n");
expect(error).toHaveBeenCalledWith(" server/file.ts:1:found");
expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing.");
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-gemini-local/server";
async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise<string> {
const commandPath = path.join(binDir, "gemini");
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
if (outPath) {
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
}
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
result: "hello",
}));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
return commandPath;
}
describe("gemini_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
"workspace",
);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: process.execPath,
cwd,
},
});
expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
it("passes model and yolo flags to the hello probe", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
const argsCapturePath = path.join(root, "args.json");
await fs.mkdir(binDir, { recursive: true });
await writeFakeGeminiCommand(binDir, argsCapturePath);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: "gemini",
cwd,
model: "gemini-2.5-pro",
yolo: true,
env: {
GEMINI_API_KEY: "test-key",
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).not.toBe("fail");
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
expect(args).toContain("--model");
expect(args).toContain("gemini-2.5-pro");
expect(args).toContain("--approval-mode");
expect(args).toContain("yolo");
await fs.rm(root, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,189 @@
import { describe, expect, it, vi } from "vitest";
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
describe("gemini_local parser", () => {
it("extracts session, summary, usage, cost, and terminal error message", () => {
const stdout = [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "output_text", text: "hello" }],
},
}),
JSON.stringify({
type: "result",
subtype: "success",
session_id: "gemini-session-1",
usage: {
promptTokenCount: 12,
cachedContentTokenCount: 3,
candidatesTokenCount: 7,
},
total_cost_usd: 0.00123,
result: "done",
}),
JSON.stringify({ type: "error", message: "model access denied" }),
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.sessionId).toBe("gemini-session-1");
expect(parsed.summary).toBe("hello");
expect(parsed.usage).toEqual({
inputTokens: 12,
cachedInputTokens: 3,
outputTokens: 7,
});
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
expect(parsed.errorMessage).toBe("model access denied");
});
it("extracts structured questions", () => {
const stdout = [
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I have a question." },
{
type: "question",
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash" },
],
},
],
},
}),
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.summary).toBe("I have a question.");
expect(parsed.question).toEqual({
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash", description: undefined },
],
});
});
});
describe("gemini_local stale session detection", () => {
it("treats missing session messages as an unknown session error", () => {
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
});
});
describe("gemini_local ui stdout parser", () => {
it("parses assistant, thinking, and result events", () => {
const ts = "2026-03-08T00:00:00.000Z";
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I checked the repo." },
{ type: "thinking", text: "Reviewing adapter registry" },
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
],
},
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I checked the repo." },
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
]);
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
},
total_cost_usd: 0.00042,
is_error: false,
}),
ts,
),
).toEqual([
{
kind: "result",
ts,
text: "Done",
inputTokens: 10,
outputTokens: 5,
cachedTokens: 2,
costUsd: 0.00042,
subtype: "success",
isError: false,
errors: [],
},
]);
});
});
function stripAnsi(value: string): string {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("gemini_local cli formatter", () => {
it("prints init, assistant, result, and error events", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
let joined = "";
try {
printGeminiStreamEvent(
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
},
total_cost_usd: 0.00042,
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({ type: "error", message: "boom" }),
false,
);
joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
} finally {
spy.mockRestore();
}
expect(joined).toContain("Gemini init");
expect(joined).toContain("assistant: hello");
expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
expect(joined).toContain("error: boom");
});
});

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execute } from "@paperclipai/adapter-gemini-local/server";
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const payload = {
argv: process.argv.slice(2),
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort(),
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
}
console.log(JSON.stringify({
type: "system",
subtype: "init",
session_id: "gemini-session-1",
model: "gemini-2.5-pro",
}));
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
session_id: "gemini-session-1",
result: "ok",
}));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
paperclipEnvKeys: string[];
};
describe("gemini execute", () => {
it("passes prompt as final argument and injects paperclip env vars", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
let invocationPrompt = "";
try {
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
invocationPrompt = meta.prompt ?? "";
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--output-format");
expect(capture.argv).toContain("stream-json");
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_API_KEY",
"PAPERCLIP_API_URL",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_RUN_ID",
]),
);
expect(invocationPrompt).toContain("Paperclip runtime note:");
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
expect(invocationPrompt).toContain("Paperclip API access note:");
expect(invocationPrompt).toContain("run_shell_command");
expect(result.question).toBeNull();
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("always passes --approval-mode yolo", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-yolo",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
},
context: {},
authToken: "t",
onLog: async () => {},
});
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv).not.toContain("--policy");
expect(capture.argv).not.toContain("--allow-all");
expect(capture.argv).not.toContain("--allow-read");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
describe("summarizeHeartbeatRunResultJson", () => {
it("truncates text fields and preserves cost aliases", () => {
const summary = summarizeHeartbeatRunResultJson({
summary: "a".repeat(600),
result: "ok",
message: "done",
error: "failed",
total_cost_usd: 1.23,
cost_usd: 0.45,
costUsd: 0.67,
nested: { ignored: true },
});
expect(summary).toEqual({
summary: "a".repeat(500),
result: "ok",
message: "done",
error: "failed",
total_cost_usd: 1.23,
cost_usd: 0.45,
costUsd: 0.67,
});
});
it("returns null for non-object and irrelevant payloads", () => {
expect(summarizeHeartbeatRunResultJson(null)).toBeNull();
expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<string, unknown>)).toBeNull();
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
resolveIssueGoalId,
resolveNextIssueGoalId,
} from "../services/issue-goal-fallback.ts";
describe("issue goal fallback", () => {
it("assigns the company goal when creating an issue without project or goal", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("keeps an explicit goal when creating an issue", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
it("backfills the company goal on update for legacy no-project issues", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
`win=C:\\Users\\${userName}\\paperclip`,
].join("\n");
const result = redactCurrentUserText(input, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
userNames: [userName],
homeDirs: [],
},
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
nested: {
author: userName,
},
values: [userName, `/home/${userName}/project`],
}, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
});
});
});

View File

@@ -456,33 +456,6 @@ describe("openclaw gateway adapter execute", () => {
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(payload?.paperclip).toEqual(
expect.objectContaining({
runId: "run-123",
companyId: "company-123",
agentId: "agent-123",
taskId: "task-123",
issueId: "issue-123",
workspace: expect.objectContaining({
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
}),
workspaces: [
expect.objectContaining({
id: "workspace-1",
cwd: "/tmp/project",
}),
],
workspaceRuntime: expect.objectContaining({
services: [
expect.objectContaining({
name: "preview",
lifecycle: "ephemeral",
}),
],
}),
}),
);
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {

View File

@@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "bash",
toolUseId: "call_1",
input: { command: "ls -1" },
},
{

View File

@@ -52,5 +52,5 @@ describe("privateHostnameGuard", () => {
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(403);
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
});
}, 20_000);
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
const TEMPLATE = `<!doctype html>
<head>
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- PAPERCLIP_FAVICON_END -->
</head>`;
describe("ui branding", () => {
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
});
it("renders the worktree favicon asset set when enabled", () => {
const links = renderFaviconLinks(true);
expect(links).toContain("/worktree-favicon.ico");
expect(links).toContain("/worktree-favicon.svg");
expect(links).toContain("/worktree-favicon-32x32.png");
expect(links).toContain("/worktree-favicon-16x16.png");
});
it("rewrites the favicon block for worktree instances only", () => {
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
expect(branded).toContain("/worktree-favicon.svg");
expect(branded).not.toContain('href="/favicon.svg"');
const defaultHtml = applyUiBranding(TEMPLATE, {});
expect(defaultHtml).toContain('href="/favicon.svg"');
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
});
});

View File

@@ -17,6 +17,12 @@ import {
sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
import {
execute as geminiExecute,
testEnvironment as geminiTestEnvironment,
sessionCodec as geminiSessionCodec,
} from "@paperclipai/adapter-gemini-local/server";
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
import {
execute as openCodeExecute,
testEnvironment as openCodeTestEnvironment,
@@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
sessionCodec: geminiSessionCodec,
models: geminiModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: geminiAgentConfigurationDoc,
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
@@ -118,6 +134,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
geminiLocalAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,

View File

@@ -24,6 +24,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { applyUiBranding } from "./ui-branding.js";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
type UiMode = "none" | "static" | "vite-dev";
@@ -135,7 +136,7 @@ export async function createApp(
];
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
if (uiDist) {
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
app.use(express.static(uiDist));
app.get(/.*/, (_req, res) => {
res.status(200).set("Content-Type", "text/html").end(indexHtml);
@@ -168,7 +169,7 @@ export async function createApp(
try {
const templatePath = path.resolve(uiRoot, "index.html");
const template = fs.readFileSync(templatePath, "utf-8");
const html = await vite.transformIndexHtml(req.originalUrl, template);
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (err) {
next(err);

View File

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

138
server/src/log-redaction.ts Normal file
View File

@@ -0,0 +1,138 @@
import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]";
interface CurrentUserRedactionOptions {
replacement?: string;
userNames?: string[];
homeDirs?: string[];
}
type CurrentUserCandidates = {
userNames: string[];
homeDirs: string[];
replacement: string;
};
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;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function uniqueNonEmpty(values: Array<string | null | undefined>) {
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
}
function splitPathSegments(value: string) {
return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean);
}
function replaceLastPathSegment(pathValue: string, replacement: string) {
const normalized = pathValue.replace(/[\\/]+$/, "");
const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
if (lastSeparator < 0) return replacement;
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
}
function defaultUserNames() {
const candidates = [
process.env.USER,
process.env.LOGNAME,
process.env.USERNAME,
];
try {
candidates.push(os.userInfo().username);
} catch {
// Some environments do not expose userInfo; env vars are enough fallback.
}
return uniqueNonEmpty(candidates);
}
function defaultHomeDirs(userNames: string[]) {
const candidates: Array<string | null | undefined> = [
process.env.HOME,
process.env.USERPROFILE,
];
try {
candidates.push(os.homedir());
} catch {
// Ignore and fall back to env hints below.
}
for (const userName of userNames) {
candidates.push(`/Users/${userName}`);
candidates.push(`/home/${userName}`);
candidates.push(`C:\\Users\\${userName}`);
}
return uniqueNonEmpty(candidates);
}
let cachedCurrentUserCandidates: CurrentUserCandidates | null = null;
function getDefaultCurrentUserCandidates(): CurrentUserCandidates {
if (cachedCurrentUserCandidates) return cachedCurrentUserCandidates;
const userNames = defaultUserNames();
cachedCurrentUserCandidates = {
userNames,
homeDirs: defaultHomeDirs(userNames),
replacement: CURRENT_USER_REDACTION_TOKEN,
};
return cachedCurrentUserCandidates;
}
function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
const defaults = getDefaultCurrentUserCandidates();
const userNames = uniqueNonEmpty(opts?.userNames ?? defaults.userNames);
const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaults.homeDirs);
const replacement = opts?.replacement?.trim() || defaults.replacement;
return { userNames, homeDirs, replacement };
}
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment)
? replaceLastPathSegment(homeDir, replacement)
: replacement;
result = result.split(homeDir).join(replacementDir);
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
}
return result;
}
export function redactCurrentUserValue<T>(value: T, opts?: CurrentUserRedactionOptions): T {
if (typeof value === "string") {
return redactCurrentUserText(value, opts) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactCurrentUserValue(entry, opts)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactCurrentUserValue(entry, opts);
}
return redacted as T;
}

View File

@@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
const sharedOpts = {
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
singleLine: true,
};
export const logger = pino({

View File

@@ -22,6 +22,13 @@ export function activityRoutes(db: Db) {
const svc = activityService(db);
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issueSvc.getByIdentifier(rawId);
}
return issueSvc.getById(rawId);
}
router.get("/companies/:companyId/activity", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -47,42 +54,27 @@ export function activityRoutes(db: Db) {
res.status(201).json(event);
});
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs
router.param("id", async (req, res, next, rawId) => {
try {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await issueSvc.getByIdentifier(rawId);
if (issue) {
req.params.id = issue.id;
}
}
next();
} catch (err) {
next(err);
}
});
router.get("/issues/:id/activity", async (req, res) => {
const id = req.params.id as string;
const issue = await issueSvc.getById(id);
const rawId = req.params.id as string;
const issue = await resolveIssueByRef(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const result = await svc.forIssue(id);
const result = await svc.forIssue(issue.id);
res.json(result);
});
router.get("/issues/:id/runs", async (req, res) => {
const id = req.params.id as string;
const issue = await issueSvc.getById(id);
const rawId = req.params.id as string;
const issue = await resolveIssueByRef(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const result = await svc.runsForIssue(issue.companyId, id);
const result = await svc.runsForIssue(issue.companyId, issue.id);
res.json(result);
});

View File

@@ -8,9 +8,11 @@ import {
createAgentKeySchema,
createAgentHireSchema,
createAgentSchema,
deriveAgentUrlKey,
isUuidLike,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type InstanceSchedulerHeartbeatAgent,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
wakeAgentSchema,
@@ -31,18 +33,21 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
};
@@ -199,6 +204,21 @@ export function agentRoutes(db: Db) {
return null;
}
function parseNumberLike(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return null;
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
return {
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
};
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
@@ -232,6 +252,10 @@ export function agentRoutes(db: Db) {
}
return ensureGatewayDeviceKey(adapterType, next);
}
if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
@@ -447,6 +471,81 @@ export function agentRoutes(db: Db) {
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
});
router.get("/instance/scheduler-heartbeats", async (req, res) => {
assertBoard(req);
const accessConditions = [];
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
const allowedCompanyIds = req.actor.companyIds ?? [];
if (allowedCompanyIds.length === 0) {
res.json([]);
return;
}
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
}
const rows = await db
.select({
id: agentsTable.id,
companyId: agentsTable.companyId,
agentName: agentsTable.name,
role: agentsTable.role,
title: agentsTable.title,
status: agentsTable.status,
adapterType: agentsTable.adapterType,
runtimeConfig: agentsTable.runtimeConfig,
lastHeartbeatAt: agentsTable.lastHeartbeatAt,
companyName: companies.name,
companyIssuePrefix: companies.issuePrefix,
})
.from(agentsTable)
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
.orderBy(companies.name, agentsTable.name);
const items: InstanceSchedulerHeartbeatAgent[] = rows
.map((row) => {
const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig);
const statusEligible =
row.status !== "paused" &&
row.status !== "terminated" &&
row.status !== "pending_approval";
return {
id: row.id,
companyId: row.companyId,
companyName: row.companyName,
companyIssuePrefix: row.companyIssuePrefix,
agentName: row.agentName,
agentUrlKey: deriveAgentUrlKey(row.agentName, row.id),
role: row.role as InstanceSchedulerHeartbeatAgent["role"],
title: row.title,
status: row.status as InstanceSchedulerHeartbeatAgent["status"],
adapterType: row.adapterType,
intervalSec: policy.intervalSec,
heartbeatEnabled: policy.enabled,
schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0,
lastHeartbeatAt: row.lastHeartbeatAt,
};
})
.filter((item) =>
item.intervalSec > 0 &&
item.status !== "paused" &&
item.status !== "terminated" &&
item.status !== "pending_approval",
)
.sort((left, right) => {
if (left.schedulerActive !== right.schedulerActive) {
return left.schedulerActive ? -1 : 1;
}
const companyOrder = left.companyName.localeCompare(right.companyName);
if (companyOrder !== 0) return companyOrder;
return left.agentName.localeCompare(right.agentName);
});
res.json(items);
});
router.get("/companies/:companyId/org", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -1346,6 +1445,17 @@ export function agentRoutes(db: Db) {
res.json(liveRuns);
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, run.companyId);
res.json(redactCurrentUserValue(run));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
@@ -1378,10 +1488,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const redactedEvents = events.map((event) => ({
...event,
payload: redactEventPayload(event.payload),
}));
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
...event,
payload: redactEventPayload(event.payload),
}),
);
res.json(redactedEvents);
});
@@ -1478,7 +1590,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...run,
...redactCurrentUserValue(run),
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,

View File

@@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
});
});
router.post("/issues/:id/read", async (req, res) => {

View File

@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { issueService } from "../services/issues.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
const issueSvc = issueService(db);
const access = accessService(db);
const dashboard = dashboardService(db);
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
joinRequests: joinRequestCount,
});
const summary = await dashboard.summary(companyId);
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
res.json(badges);
});

View File

@@ -1,6 +1,7 @@
import type { Db } from "@paperclipai/db";
import { activityLog } from "@paperclipai/db";
import { publishLiveEvent } from "./live-events.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
export interface LogActivityInput {
@@ -17,6 +18,7 @@ export interface LogActivityInput {
export async function logActivity(db: Db, input: LogActivityInput) {
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,
@@ -26,7 +28,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
details: sanitizedDetails,
details: redactedDetails,
});
publishLiveEvent({
@@ -40,7 +42,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
details: sanitizedDetails,
details: redactedDetails,
},
});
}

View File

@@ -2,9 +2,17 @@ import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
@@ -215,7 +223,8 @@ export function approvalService(db: Db) {
eq(approvalComments.companyId, existing.companyId),
),
)
.orderBy(asc(approvalComments.createdAt));
.orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
},
addComment: async (
@@ -224,6 +233,7 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
return db
.insert(approvalComments)
.values({
@@ -231,10 +241,10 @@ export function approvalService(db: Db) {
approvalId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
body: redactedBody,
})
.returning()
.then((rows) => rows[0]);
.then((rows) => redactApprovalComment(rows[0]));
},
};
}

View File

@@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
],
gemini_local: [
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
],
opencode_local: [
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
@@ -81,7 +85,7 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
claude_local: [
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
{ path: ["maxTurnsPerRun"], value: 80 },
{ path: ["maxTurnsPerRun"], value: 300 },
],
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },

View File

@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.then((rows) => Number(rows[0]?.count ?? 0));
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
const staleTasks = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
const agentCounts: Record<string, number> = {
active: 0,
running: 0,
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
staleTasks,
};
},
};

View File

@@ -1,7 +1,47 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { goals } from "@paperclipai/db";
type GoalReader = Pick<Db, "select">;
export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) {
const activeRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
eq(goals.status, "active"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (activeRootGoal) return activeRootGoal;
const anyRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (anyRootGoal) return anyRootGoal;
return db
.select()
.from(goals)
.where(and(eq(goals.companyId, companyId), eq(goals.level, "company")))
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
}
export function goalService(db: Db) {
return {
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
@@ -13,6 +53,8 @@ export function goalService(db: Db) {
.where(eq(goals.id, id))
.then((rows) => rows[0] ?? null),
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
db
.insert(goals)

View File

@@ -0,0 +1,35 @@
function truncateSummaryText(value: unknown, maxLength = 500) {
if (typeof value !== "string") return null;
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
function readNumericField(record: Record<string, unknown>, key: string) {
return key in record ? record[key] ?? null : undefined;
}
export function summarizeHeartbeatRunResultJson(
resultJson: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
return null;
}
const summary: Record<string, unknown> = {};
const textFields = ["summary", "result", "message", "error"] as const;
for (const key of textFields) {
const value = truncateSummaryText(resultJson[key]);
if (value !== null) {
summary[key] = value;
}
}
const numericFieldAliases = ["total_cost_usd", "cost_usd", "costUsd"] as const;
for (const key of numericFieldAliases) {
const value = readNumericField(resultJson, key);
if (value !== undefined && value !== null) {
summary[key] = value;
}
}
return Object.keys(summary).length > 0 ? summary : null;
}

View File

@@ -9,7 +9,6 @@ import {
agentWakeupRequests,
heartbeatRunEvents,
heartbeatRuns,
costEvents,
issues,
projects,
projectWorkspaces,
@@ -22,8 +21,10 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
import {
buildWorkspaceReadyComment,
ensureRuntimeServicesForRun,
@@ -38,6 +39,7 @@ import {
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -46,6 +48,37 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
status: heartbeatRuns.status,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
error: heartbeatRuns.error,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
exitCode: heartbeatRuns.exitCode,
signal: heartbeatRuns.signal,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
logStore: heartbeatRuns.logStore,
logRef: heartbeatRuns.logRef,
logBytes: heartbeatRuns.logBytes,
logSha256: heartbeatRuns.logSha256,
logCompressed: heartbeatRuns.logCompressed,
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
errorCode: heartbeatRuns.errorCode,
externalRunId: heartbeatRuns.externalRunId,
contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt,
updatedAt: heartbeatRuns.updatedAt,
} as const;
function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
@@ -422,6 +455,7 @@ export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
const activeRunExecutions = new Set<string>();
async function getAgent(agentId: string) {
return db
@@ -779,6 +813,9 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
runId: run.id,
@@ -788,8 +825,8 @@ export function heartbeatService(db: Db) {
stream: event.stream,
level: event.level,
color: event.color,
message: event.message,
payload: event.payload,
message: sanitizedMessage,
payload: sanitizedPayload,
});
publishLiveEvent({
@@ -803,8 +840,8 @@ export function heartbeatService(db: Db) {
stream: event.stream ?? null,
level: event.level ?? null,
color: event.color ?? null,
message: event.message ?? null,
payload: event.payload ?? null,
message: sanitizedMessage ?? null,
payload: sanitizedPayload ?? null,
},
});
}
@@ -923,7 +960,7 @@ export function heartbeatService(db: Db) {
const reaped: string[] = [];
for (const run of activeRuns) {
if (runningProcesses.has(run.id)) continue;
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
// Apply staleness threshold to avoid false positives
if (staleThresholdMs > 0) {
@@ -962,6 +999,18 @@ export function heartbeatService(db: Db) {
return { reaped: reaped.length, runIds: reaped };
}
async function resumeQueuedRuns() {
const queuedRuns = await db
.select({ agentId: heartbeatRuns.agentId })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.status, "queued"));
const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))];
for (const agentId of agentIds) {
await startNextQueuedRunForAgent(agentId);
}
}
async function updateRuntimeState(
agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect,
@@ -993,8 +1042,8 @@ export function heartbeatService(db: Db) {
.where(eq(agentRuntimeState.agentId, agent.id));
if (additionalCostCents > 0 || hasTokenUsage) {
await db.insert(costEvents).values({
companyId: agent.companyId,
const costs = costService(db);
await costs.createEvent(agent.companyId, {
agentId: agent.id,
provider: result.provider ?? "unknown",
model: result.model ?? "unknown",
@@ -1004,16 +1053,6 @@ export function heartbeatService(db: Db) {
occurredAt: new Date(),
});
}
if (additionalCostCents > 0) {
await db
.update(agents)
.set({
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`,
updatedAt: new Date(),
})
.where(eq(agents.id, agent.id));
}
}
async function startNextQueuedRunForAgent(agentId: string) {
@@ -1063,6 +1102,9 @@ export function heartbeatService(db: Db) {
run = claimed;
}
activeRunExecutions.add(run.id);
try {
const agent = await getAgent(run.agentId);
if (!agent) {
await setRunStatus(runId, "failed", {
@@ -1303,21 +1345,23 @@ export function heartbeatService(db: Db) {
.where(eq(heartbeatRuns.id, runId));
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
const sanitizedChunk = redactCurrentUserText(chunk);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
if (handle) {
await runLogStore.append(handle, {
stream,
chunk,
ts: new Date().toISOString(),
chunk: sanitizedChunk,
ts,
});
}
const payloadChunk =
chunk.length > MAX_LIVE_LOG_CHUNK_BYTES
? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
: chunk;
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
: sanitizedChunk;
publishLiveEvent({
companyId: run.companyId,
@@ -1325,9 +1369,10 @@ export function heartbeatService(db: Db) {
payload: {
runId: run.id,
agentId: run.agentId,
ts,
stream,
chunk: payloadChunk,
truncated: payloadChunk.length !== chunk.length,
truncated: payloadChunk.length !== sanitizedChunk.length,
},
});
};
@@ -1518,7 +1563,9 @@ export function heartbeatService(db: Db) {
error:
outcome === "succeeded"
? null
: adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
),
errorCode:
outcome === "timed_out"
? "timeout"
@@ -1585,7 +1632,7 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown adapter failure";
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -1645,10 +1692,41 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, "failed");
} finally {
await releaseRuntimeServicesForRun(run.id);
await startNextQueuedRunForAgent(agent.id);
}
} catch (outerErr) {
// Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun).
// The inner catch did not fire, so we must record the failure here.
const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure";
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
await setRunStatus(runId, "failed", {
error: message,
errorCode: "adapter_failed",
finishedAt: new Date(),
}).catch(() => undefined);
await setWakeupStatus(run.wakeupRequestId, "failed", {
finishedAt: new Date(),
error: message,
}).catch(() => undefined);
const failedRun = await getRun(runId).catch(() => null);
if (failedRun) {
// Emit a run-log event so the failure is visible in the run timeline,
// consistent with what the inner catch block does for adapter failures.
await appendRunEvent(failedRun, 1, {
eventType: "error",
stream: "system",
level: "error",
message,
}).catch(() => undefined);
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
}
// Ensure the agent is not left stuck in "running" if the inner catch handler's
// DB calls threw (e.g. a transient DB error in finalizeAgentStatus).
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
} finally {
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
activeRunExecutions.delete(run.id);
await startNextQueuedRunForAgent(run.agentId);
}
}
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
@@ -2260,9 +2338,9 @@ export function heartbeatService(db: Db) {
}
return {
list: (companyId: string, agentId?: string, limit?: number) => {
list: async (companyId: string, agentId?: string, limit?: number) => {
const query = db
.select()
.select(heartbeatRunListColumns)
.from(heartbeatRuns)
.where(
agentId
@@ -2271,10 +2349,11 @@ export function heartbeatService(db: Db) {
)
.orderBy(desc(heartbeatRuns.createdAt));
if (limit) {
return query.limit(limit);
}
return query;
const rows = limit ? await query.limit(limit) : await query;
return rows.map((row) => ({
...row,
resultJson: summarizeHeartbeatRunResultJson(row.resultJson),
}));
},
getRun,
@@ -2370,6 +2449,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
};
},
@@ -2392,6 +2472,8 @@ export function heartbeatService(db: Db) {
reapOrphanedRuns,
resumeQueuedRuns,
tickTimers: async (now = new Date()) => {
const allAgents = await db.select().from(agents);
let checked = 0;

View File

@@ -0,0 +1,30 @@
type MaybeId = string | null | undefined;
export function resolveIssueGoalId(input: {
projectId: MaybeId;
goalId: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
if (!input.projectId && !input.goalId) {
return input.defaultGoalId ?? null;
}
return input.goalId ?? null;
}
export function resolveNextIssueGoalId(input: {
currentProjectId: MaybeId;
currentGoalId: MaybeId;
projectId?: MaybeId;
goalId?: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
const projectId =
input.projectId !== undefined ? input.projectId : input.currentProjectId;
const goalId =
input.goalId !== undefined ? input.goalId : input.currentGoalId;
if (!projectId && !goalId) {
return input.defaultGoalId ?? null;
}
return goalId ?? null;
}

View File

@@ -22,6 +22,9 @@ import {
defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -88,6 +91,13 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@@ -641,6 +651,7 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
@@ -665,6 +676,11 @@ export function issueService(db: Db) {
const values = {
...issueData,
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
@@ -744,6 +760,14 @@ export function issueService(db: Db) {
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
const updated = await tx
.update(issues)
.set(patch)
@@ -1041,14 +1065,18 @@ export function issueService(db: Db) {
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt)),
.orderBy(desc(issueComments.createdAt))
.then((comments) => comments.map(redactIssueComment)),
getComment: (commentId: string) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => rows[0] ?? null),
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@@ -1059,6 +1087,7 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const [comment] = await db
.insert(issueComments)
.values({
@@ -1066,7 +1095,7 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
body: redactedBody,
})
.returning();
@@ -1076,7 +1105,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return comment;
return redactIssueComment(comment);
},
createAttachment: async (input: {
@@ -1411,23 +1440,5 @@ export function issueService(db: Db) {
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
}));
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
isNull(issues.hiddenAt),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
};
}

41
server/src/ui-branding.ts Normal file
View File

@@ -0,0 +1,41 @@
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
const DEFAULT_FAVICON_LINKS = [
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
'<link rel="icon" href="/favicon.svg" type="image/svg+xml" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
].join("\n");
const WORKTREE_FAVICON_LINKS = [
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
].join("\n");
function isTruthyEnvValue(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
}
export function renderFaviconLinks(worktree: boolean): string {
return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS;
}
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
const start = html.indexOf(FAVICON_BLOCK_START);
const end = html.indexOf(FAVICON_BLOCK_END);
if (start === -1 || end === -1 || end < start) return html;
const before = html.slice(0, start + FAVICON_BLOCK_START.length);
const after = html.slice(end);
const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env));
return `${before}\n${links}\n ${after}`;
}

View File

@@ -103,10 +103,12 @@ POST /api/companies/{companyId}/openclaw/invite-prompt
```
Access control:
- Board users with invite permission can call it.
- Agent callers: only the company CEO agent can call it.
2. Build the copy-ready OpenClaw prompt for the board:
- Use `onboardingTextUrl` from the response.
- Ask the board to paste that prompt into OpenClaw.
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
@@ -131,6 +133,7 @@ Access control:
- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only.
- **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them.
- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows.
- **Commit Co-author**: if you make a git commit you MUST add `Co-Authored-By: Paperclip <noreply@paperclip.ing>` to the end of each commit message
## Comment Style (Required)
@@ -203,6 +206,7 @@ PATCH /api/agents/{agentId}/instructions-path
```
Rules:
- Allowed for: the target agent itself, or an ancestor manager in that agent's reporting chain.
- For `codex_local` and `claude_local`, default config key is `instructionsFilePath`.
- Relative paths are resolved against the target agent's `adapterConfig.cwd`; absolute paths are accepted as-is.
@@ -219,25 +223,25 @@ PATCH /api/agents/{agentId}/instructions-path
## Key Endpoints (Quick Reference)
| Action | Endpoint |
| -------------------- | ------------------------------------------------------------------------------------------ |
| My identity | `GET /api/agents/me` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Get comments | `GET /api/issues/:issueId/comments` |
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Add comment | `POST /api/issues/:issueId/comments` |
| Create subtask | `POST /api/companies/:companyId/issues` |
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
| Create project | `POST /api/companies/:companyId/projects` |
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| Release task | `POST /api/issues/:issueId/release` |
| List agents | `GET /api/companies/:companyId/agents` |
| Dashboard | `GET /api/companies/:companyId/dashboard` |
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
| Action | Endpoint |
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
| My identity | `GET /api/agents/me` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Get comments | `GET /api/issues/:issueId/comments` |
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Add comment | `POST /api/issues/:issueId/comments` |
| Create subtask | `POST /api/companies/:companyId/issues` |
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
| Create project | `POST /api/companies/:companyId/projects` |
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| Release task | `POST /api/issues/:issueId/release` |
| List agents | `GET /api/companies/:companyId/agents` |
| Dashboard | `GET /api/companies/:companyId/dashboard` |
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
## Searching Issues

View File

@@ -8,10 +8,12 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" />
<title>Paperclip</title>
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- PAPERCLIP_FAVICON_END -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script>

View File

@@ -17,6 +17,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:*",

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<style>
path { stroke: #db2777; }
@media (prefers-color-scheme: dark) {
path { stroke: #f472b6; }
}
</style>
<path stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation } from "@/lib/router";
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Layout } from "./components/Layout";
@@ -23,14 +23,18 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { InviteLandingPage } from "./pages/InviteLanding";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
@@ -104,8 +108,11 @@ function boardRoutes() {
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="org" element={<OrgChart />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
@@ -137,14 +144,78 @@ function boardRoutes() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
const { companies, loading } = useCompany();
const { onboardingOpen, openOnboarding } = useDialog();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const opened = useRef(false);
const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
: null;
useEffect(() => {
if (loading || opened.current || onboardingOpen) return;
opened.current = true;
if (matchedCompany) {
openOnboarding({ initialStep: 2, companyId: matchedCompany.id });
return;
}
openOnboarding();
}, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]);
const title = matchedCompany
? `Add another agent to ${matchedCompany.name}`
: companies.length > 0
? "Create another company"
: "Create your first company";
const description = matchedCompany
? "Run onboarding again to add an agent and a starter task for this company."
: companies.length > 0
? "Run onboarding again to create another company and seed its first agent."
: "Get started by creating a company and your first agent.";
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<div className="mt-4">
<Button
onClick={() =>
matchedCompany
? openOnboarding({ initialStep: 2, companyId: matchedCompany.id })
: openOnboarding()
}
>
{matchedCompany ? "Add Agent" : "Start Onboarding"}
</Button>
</div>
</div>
</div>
);
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();
@@ -223,9 +294,16 @@ export function App() {
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<InstanceSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
@@ -237,9 +315,11 @@ export function App() {
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
</Routes>
<OnboardingWizard />

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