Compare commits

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

17 commits

Author SHA1 Message Date
dotta
627a4d7995 docs: nightly accuracy fixes 2026-03-29
- CONTRIBUTING.md: remove references to Greptile bot (not configured in CI)
  and fix "lint" mention (no linter in CI pipeline)
- docs/agents-runtime.md: add missing gemini_local adapter to built-in list
- AGENTS.md: add report/ directory to repo map

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 01:04:53 -05:00
dotta
f1b6d33f0a docs: mark Multiple Human Users as done, add patches/ to repo map
- README roadmap: Multiple Human Users is implemented (invite system,
  permissions, multi-user membership) — update from  to 
- AGENTS.md repo map: add missing patches/ directory

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 01:04:25 -05:00
dotta
bec5305301 docs: add evals and releases directories to AGENTS.md repo map
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:18:31 -05:00
dotta
a07e4e5ca5 docs: documentation accuracy update 2026-03-28
- SPEC: add missing gemini_local adapter to adapter table
- SPEC: update plugin UI components from "(future)" to shipped status
- PRODUCT: replace task structure TBD with current model summary

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 22:16:27 -05:00
dotta
0cc0320ac1 docs: fix incorrect dev DB reset path in AGENTS.md
The embedded PostgreSQL data directory moved to ~/.paperclip/instances/default/db
but AGENTS.md still referenced the old data/pglite path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:16:27 -05:00
dotta
68171c4797 docs: add missing directories to AGENTS.md repo map
Add cli/, skills/, docs/, tests/, scripts/, and docker/ entries to the
repo map in AGENTS.md so contributors can locate all top-level packages.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 22:16:27 -05:00
Dotta
6a72faf83b
Merge pull request #1949 from vanductai/fix/dev-watch-tsx-cli-path
Some checks failed
Docker / build-and-push (push) Has been cancelled
Refresh Lockfile / refresh (push) Has been cancelled
Release / verify_canary (push) Has been cancelled
Release / verify_stable (push) Has been cancelled
Release / publish_canary (push) Has been cancelled
Release / preview_stable (push) Has been cancelled
Release / publish_stable (push) Has been cancelled
fix(server): use stable tsx/cli entry point in dev-watch
2026-03-28 16:45:04 -05:00
Dotta
1fd40920db
Merge pull request #1974 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-28 06:50:53 -05:00
lockfile-bot
caef115b95 chore(lockfile): refresh pnpm-lock.yaml 2026-03-28 11:46:21 +00:00
Dotta
17e5322e28
Merge pull request #1955 from HenkDz/feat/hermes-adapter-upgrade
feat(hermes): upgrade hermes-paperclip-adapter + UI adapter, skills, model detection
2026-03-28 06:46:01 -05:00
HenkDz
582f4ceaf4 fix: address Hermes adapter review feedback 2026-03-28 11:35:58 +01:00
HenkDz
1583a2d65a feat(hermes): upgrade hermes-paperclip-adapter + UI adapter + skills + detectModel
Upgrades hermes-paperclip-adapter from 0.1.1 to ^0.2.0 and wires in all new
capabilities introduced in v0.2.0:

Server
- Upgrade hermes-paperclip-adapter 0.1.1 -> ^0.2.0 (pending PR#10 merge)
- Wire listSkills + syncSkills from hermes-paperclip-adapter/server
- Add detectModel to hermesLocalAdapter (reads ~/.hermes/config.yaml)
- Add detectAdapterModel() function + /adapters/:type/detect-model route
- Export detectAdapterModel from server/src/adapters/index.ts

Types
- Add optional detectModel? to ServerAdapterModule in adapter-utils

UI
- Add hermes-paperclip-adapter ^0.2.0 to ui/package.json (for /ui exports)
- New ui/src/adapters/hermes-local/ — config fields + UI adapter module
- Register hermesLocalUIAdapter in UI adapter registry
- New HermesIcon (caduceus SVG) for adapter pickers
- AgentConfigForm: detect-model button, creatable model input, preserve
  adapter-agnostic fields (env, promptTemplate) when switching adapter type
- NewAgentDialog + OnboardingWizard: add Hermes to adapter picker
- Agents, OrgChart, InviteLanding, NewAgent, agent-config-primitives: add
  hermes_local label + enable in adapter sets
- AgentDetail: smarter run summary excerpt extraction
- RunTranscriptView: improved Hermes stdout rendering

NOTE: requires hermes-paperclip-adapter@0.2.0 on npm.
      Blocked on NousResearch/hermes-paperclip-adapter#10 merging.
2026-03-28 01:34:48 +01:00
vanductai
9a70a4edaa fix(server): use stable tsx/cli entry point in dev-watch
The dev-watch script was importing tsx via the internal path
'tsx/dist/cli.mjs', which is an undocumented implementation detail
that broke when tsx updated its internal structure.

Switched to the stable public export 'tsx/cli' which is the
officially supported entry point and won't break across versions.
2026-03-28 06:42:03 +07:00
Dotta
0ac01a04e5
Merge pull request #1891 from paperclipai/docs/maintenance-20260327-public
docs: documentation accuracy update 2026-03-27
2026-03-27 07:47:24 -05:00
dotta
11ff24cd22 docs: fix adapter type references and complete adapter table
- Fix openclaw → openclaw_gateway type key in adapters overview and managing-agents guide
- Add missing adapters to overview table: hermes_local, cursor, pi_local
- Mark gemini_local as experimental (adapter package exists but not in stable type enum)
- Update "Choosing an Adapter" recommendations to match stable adapter set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:05:08 -05:00
Devin Foley
a5d47166e2
docs: add board-operator delegation guide (#1889)
* docs: add board-operator delegation guide

Create docs/guides/board-operator/delegation.md explaining the full
CEO-led delegation lifecycle from the board operator's perspective.
Covers what the board needs to do, what the CEO automates, common
delegation patterns (flat, 3-level, hire-on-demand), and a
troubleshooting section that directly answers the #1 new-user
confusion point: "Do I have to tell the CEO to delegate?"

Also adds a Delegation section to core-concepts.md and wires the
new guide into docs.json navigation after Managing Tasks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: add AGENTS.md troubleshooting note to delegation guide

Add a row to the troubleshooting table telling board operators to
verify the CEO's AGENTS.md instructions file contains delegation
directives. Without these instructions, the CEO won't delegate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: fix stale concept count and frontmatter summary

Update "five key concepts" to "six" and add "delegation" to the
frontmatter summary field, addressing Greptile review comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-03-26 23:01:58 -07:00
Dotta
af5b980362
Merge pull request #1857 from paperclipai/PAP-878-create-a-mine-tab-in-inbox
Add a Mine tab and archive flow to inbox
2026-03-26 16:21:47 -05:00
36 changed files with 815 additions and 57 deletions

1
.doc-review-cursor Normal file
View file

@ -0,0 +1 @@
6a72faf83b3aeb35ee44da51898fe8cbce6f5002

View file

@ -24,12 +24,22 @@ Before making changes, read in this order:
- `server/`: Express REST API and orchestration services
- `ui/`: React + Vite board UI
- `cli/`: CLI package (`paperclipai` command)
- `packages/db/`: Drizzle schema, migrations, DB clients
- `packages/shared/`: shared types, constants, validators, API path constants
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
- `packages/adapter-utils/`: shared adapter utilities
- `packages/plugins/`: plugin system packages
- `skills/`: agent skills (Paperclip skill, company skills)
- `doc/`: operational and product docs
- `docs/`: Mintlify documentation site
- `tests/`: end-to-end and release smoke tests
- `scripts/`: build, dev, and release scripts
- `evals/`: promptfoo evaluation suites
- `releases/`: release notes
- `patches/`: pnpm dependency patches
- `docker/`: Docker configuration
- `report/`: generated analysis and audit reports
## 4. Dev Setup (Auto DB)
@ -55,7 +65,7 @@ curl http://localhost:3100/api/companies
Reset local dev DB:
```sh
rm -rf data/pglite
rm -rf ~/.paperclip/instances/default/db
pnpm dev
```

View file

@ -11,8 +11,8 @@ We really appreciate both small fixes and thoughtful larger changes.
- Pick **one** clear thing to fix/improve
- Touch the **smallest possible number of files**
- Make sure the change is very targeted and easy to review
- All automated checks pass (including Greptile comments)
- No new lint/test failures
- All automated checks pass (CI typecheck, tests, build)
- No new test failures
These almost always get merged quickly when they're clean.
@ -27,7 +27,7 @@ These almost always get merged quickly when they're clean.
- Clear description of what & why
- Proof it works (manual testing notes)
- All tests passing
- All Greptile + other PR comments addressed
- All PR review comments addressed
PRs that follow this path are **much** more likely to be accepted, even when they're large.

View file

@ -244,7 +244,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ⚪ Artifacts & Deployments
- ⚪ CEO Chat
- ⚪ MAXIMIZER MODE
- Multiple Human Users
- Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
- ⚪ Cloud deployments
- ⚪ Desktop App

View file

@ -54,7 +54,7 @@ I am researching the Facebook ads Granola uses (current task)
Tasks have parentage. Every task exists in service of a parent task, all the way up to the company goal. This is what keeps autonomous agents aligned — they can always answer "why am I doing this?"
More detailed task structure TBD.
Tasks carry statuses (backlog, todo, in_progress, in_review, done, blocked, cancelled), priorities, project and goal associations, billing codes for cross-team cost attribution, and execution workspace context. See [SPEC.md](./SPEC.md) for the full task model.
## Principles

View file

@ -196,6 +196,7 @@ Agent configuration includes an **adapter** that defines how Paperclip invokes t
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
| `gemini_local` | Local Gemini process | Gemini CLI heartbeat worker |
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
@ -434,7 +435,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
- Well-defined API boundaries that plugins can hook into
- Event system or hooks for reacting to task/Agent lifecycle events
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
- Plugin-registrable UI components (future)
- Plugin-registrable UI components (toolbar buttons, pages, slots)
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.

View file

@ -20,9 +20,12 @@ 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 |
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| Hermes Local | `hermes_local` | Runs Hermes CLI locally |
| Cursor | `cursor` | Runs Cursor in background mode |
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
@ -55,7 +58,7 @@ Three registries consume these modules:
## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_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

@ -37,6 +37,7 @@ Built-in adapters:
- `claude_local`: runs your local `claude` CLI
- `codex_local`: runs your local `codex` CLI
- `opencode_local`: runs your local `opencode` CLI
- `gemini_local`: runs your local Gemini CLI
- `hermes_local`: runs your local `hermes` CLI
- `cursor`: runs Cursor in background mode
- `pi_local`: runs an embedded Pi agent locally
@ -44,7 +45,7 @@ Built-in adapters:
- `process`: generic shell command adapter
- `http`: calls an external HTTP endpoint
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `gemini_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
## 3.2 Runtime behavior

View file

@ -46,6 +46,7 @@
"guides/board-operator/managing-agents",
"guides/board-operator/org-structure",
"guides/board-operator/managing-tasks",
"guides/board-operator/delegation",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log",

View file

@ -0,0 +1,122 @@
---
title: How Delegation Works
summary: How the CEO breaks down goals into tasks and assigns them to agents
---
Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator.
## The Delegation Lifecycle
When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team:
```
You set a company goal
→ CEO wakes up on heartbeat
→ CEO proposes a strategy (creates an approval for you)
→ You approve the strategy
→ CEO breaks goals into tasks and assigns them to reports
→ Reports wake up (heartbeat triggered by assignment)
→ Reports execute work and update task status
→ CEO monitors progress, unblocks, and escalates
→ You see results in the dashboard and activity log
```
Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening.
## What You Need to Do
Your role is strategic oversight, not task management. Here's what the delegation model expects from you:
1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better.
2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions.
3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving.
4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates.
5. **Intervene only when things stall.** If progress stops, check these in order:
- Is an approval pending in your queue?
- Is an agent paused or in an error state?
- Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)?
## What the CEO Does Automatically
You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO:
- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria
- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO)
- **Creates subtasks** when work needs to be decomposed further
- **Hires new agents** when the team lacks capacity for a goal (subject to your approval)
- **Monitors progress** on each heartbeat, checking task status and unblocking reports
- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity
## Common Delegation Patterns
### Flat Hierarchy (Small Teams)
For small companies with 3-5 agents, the CEO delegates directly to each report:
```
CEO
├── CTO (engineering tasks)
├── CMO (marketing tasks)
└── Designer (design tasks)
```
The CEO assigns tasks directly. Each agent works independently and reports status back.
### Three-Level Hierarchy (Larger Teams)
For larger organizations, managers delegate further down the chain:
```
CEO
├── CTO
│ ├── Backend Engineer
│ └── Frontend Engineer
└── CMO
└── Content Writer
```
The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically.
### Hire-on-Demand
The CEO can start as the only agent and hire as work requires:
1. You set a goal that needs engineering work
2. The CEO proposes a strategy that includes hiring a CTO
3. You approve the hire
4. The CEO assigns engineering tasks to the new CTO
5. As scope grows, the CTO may request to hire engineers
This pattern lets you start small and scale the team based on actual work, not upfront planning.
## Troubleshooting
### "Why isn't the CEO delegating?"
If you've set a goal but nothing is happening, check these common causes:
| Check | What to look for |
|-------|-----------------|
| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. |
| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. |
| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. |
| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. |
| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. |
| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. |
### "Do I have to tell the CEO to engage engineering and marketing?"
**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment.
### "A task seems stuck"
If a specific task isn't progressing:
1. Check the task's comment thread — the assigned agent may have posted a blocker
2. Check if the task is in `blocked` status — read the blocker comment to understand why
3. Check the assigned agent's status — it may be paused or over budget
4. If the agent is stuck, you can reassign the task or add a comment with guidance

View file

@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
Common adapter choices:
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
- `openclaw` / `http` for webhook-based external agents
- `openclaw_gateway` / `http` for webhook-based external agents
- `process` for generic local command execution
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).

View file

@ -1,9 +1,9 @@
---
title: Core Concepts
summary: Companies, agents, issues, heartbeats, and governance
summary: Companies, agents, issues, delegation, heartbeats, and governance
---
Paperclip organizes autonomous AI work around five key concepts.
Paperclip organizes autonomous AI work around six key concepts.
## Company
@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`.
The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`.
## Delegation
The CEO is the primary delegator. When you set company goals, the CEO:
1. Creates a strategy and submits it for your approval
2. Breaks approved goals into tasks
3. Assigns tasks to agents based on their role and capabilities
4. Hires new agents when needed (subject to your approval)
You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle.
## Heartbeats
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.

View file

@ -287,6 +287,12 @@ export interface ServerAdapterModule {
* without knowing provider-specific credential paths or API shapes.
*/
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
/**
* Optional: detect the currently configured model from local config files.
* Returns the detected model/provider and the config source, or null if
* the adapter does not support detection or no config is found.
*/
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
}
// ---------------------------------------------------------------------------

21
pnpm-lock.yaml generated
View file

@ -504,8 +504,8 @@ importers:
specifier: ^5.1.0
version: 5.2.1
hermes-paperclip-adapter:
specifier: 0.1.1
version: 0.1.1
specifier: ^0.2.0
version: 0.2.0
jsdom:
specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1)
@ -639,6 +639,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
hermes-paperclip-adapter:
specifier: ^0.2.0
version: 0.2.0
lexical:
specifier: 0.35.0
version: 0.35.0
@ -2040,8 +2043,8 @@ packages:
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@paperclipai/adapter-utils@0.3.1':
resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==}
'@paperclipai/adapter-utils@2026.325.0':
resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -4468,8 +4471,8 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hermes-paperclip-adapter@0.1.1:
resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==}
hermes-paperclip-adapter@0.2.0:
resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==}
engines: {node: '>=20.0.0'}
html-encoding-sniffer@6.0.0:
@ -7740,7 +7743,7 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {}
'@paperclipai/adapter-utils@0.3.1': {}
'@paperclipai/adapter-utils@2026.325.0': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
@ -10337,9 +10340,9 @@ snapshots:
help-me@5.0.0: {}
hermes-paperclip-adapter@0.1.1:
hermes-paperclip-adapter@0.2.0:
dependencies:
'@paperclipai/adapter-utils': 0.3.1
'@paperclipai/adapter-utils': 2026.325.0
picocolors: 1.1.1
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):

View file

@ -65,7 +65,7 @@
"drizzle-orm": "^0.38.4",
"embedded-postgres": "^18.1.0-beta.16",
"express": "^5.1.0",
"hermes-paperclip-adapter": "0.1.1",
"hermes-paperclip-adapter": "^0.2.0",
"jsdom": "^28.1.0",
"multer": "^2.0.2",
"open": "^11.0.0",

View file

@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
const require = createRequire(import.meta.url);
const tsxCliPath = require.resolve("tsx/dist/cli.mjs");
const tsxCliPath = require.resolve("tsx/cli");
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);

View file

@ -1,4 +1,4 @@
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
export type {
ServerAdapterModule,
AdapterExecutionContext,

View file

@ -70,6 +70,9 @@ import {
execute as hermesExecute,
testEnvironment as hermesTestEnvironment,
sessionCodec as hermesSessionCodec,
listSkills as hermesListSkills,
syncSkills as hermesSyncSkills,
detectModel as detectModelFromHermes,
} from "hermes-paperclip-adapter/server";
import {
agentConfigurationDoc as hermesAgentConfigurationDoc,
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
execute: hermesExecute,
testEnvironment: hermesTestEnvironment,
sessionCodec: hermesSessionCodec,
listSkills: hermesListSkills,
syncSkills: hermesSyncSkills,
models: hermesModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: hermesAgentConfigurationDoc,
detectModel: () => detectModelFromHermes(),
};
const adaptersByType = new Map<string, ServerAdapterModule>(
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values());
}
export async function detectAdapterModel(
type: string,
): Promise<{ model: string; provider: string; source: string } | null> {
const adapter = adaptersByType.get(type);
if (!adapter?.detectModel) return null;
const detected = await adapter.detectModel();
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
}
export function findServerAdapter(type: string): ServerAdapterModule | null {
return adaptersByType.get(type) ?? null;
}

View file

@ -44,7 +44,7 @@ import {
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
@ -671,6 +671,15 @@ export function agentRoutes(db: Db) {
res.json(models);
});
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const type = req.params.type as string;
const detected = await detectAdapterModel(type);
res.json(detected);
});
router.post(
"/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema),

View file

@ -41,6 +41,7 @@
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"hermes-paperclip-adapter": "^0.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",

View file

@ -0,0 +1,49 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View file

@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { HermesLocalConfigFields } from "./config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: HermesLocalConfigFields,
buildAdapterConfig: buildHermesConfig,
};

View file

@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,

View file

@ -27,6 +27,12 @@ export interface AdapterModel {
label: string;
}
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
@ -159,6 +165,10 @@ export const agentsApi = {
api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: (
companyId: string,
type: string,

View file

@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType;
// When adapter type changes, send only the new config — don't merge
// with old config since old adapter fields are meaningless for the new type
patch.adapterConfig = overlay.adapterConfig;
// When adapter type changes, replace adapter-specific fields but preserve
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
// across all adapter types.
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
const adapterAgnosticKeys = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
];
const preserved: Record<string, unknown> = {};
for (const key of adapterAgnosticKeys) {
if (key in existing) {
preserved[key] = existing[key];
}
}
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
} else if (Object.keys(overlay.adapterConfig).length > 0) {
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const isHermesLocal = adapterType === "hermes_local";
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
enabled: Boolean(selectedCompanyId),
});
const models = fetchedModels ?? externalModels ?? [];
const {
data: detectedModelData,
refetch: refetchDetectedModel,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select a company to detect the Hermes model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isHermesLocal),
});
const detectedModel = detectedModelData?.model ?? null;
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
groupByProvider={adapterType === "opencode_local"}
creatable={adapterType === "hermes_local"}
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
onDetectModel={adapterType === "hermes_local"
? async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}
: undefined}
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
@ -1293,6 +1339,10 @@ function ModelDropdown({
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
onDetectModel,
detectModelLabel,
}: {
models: AdapterModel[];
value: string;
@ -1302,9 +1352,20 @@ function ModelDropdown({
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
onDetectModel?: () => Promise<string | null>;
detectModelLabel?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (!modelSearch.trim()) return true;
@ -1341,6 +1402,21 @@ function ModelDropdown({
}));
}, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return (
<Field label="Model" hint={help.model}>
<Popover
@ -1351,7 +1427,7 @@ function ModelDropdown({
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
@ -1361,16 +1437,84 @@ function ModelDropdown({
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
</button>
)}
{value && !models.some((m) => m.id === value) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{allowDefault && (
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@ -1383,6 +1527,20 @@ function ModelDropdown({
Default
</button>
)}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
@ -1392,6 +1550,7 @@ function ModelDropdown({
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@ -1409,8 +1568,14 @@ function ModelDropdown({
))}
</div>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
{filteredModels.length === 0 && !canCreateManualModel && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>

View file

@ -0,0 +1,43 @@
import { cn } from "../lib/utils";
interface HermesIconProps {
className?: string;
}
/**
* Hermes caduceus icon winged staff with two intertwined serpents.
* Replaces the generic Zap icon for the hermes_local adapter type.
*
* inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
*/
export function HermesIcon({ className }: HermesIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}

View file

@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
type AdvancedAdapterType =
| "claude_local"
@ -29,7 +30,8 @@ type AdvancedAdapterType =
| "opencode_local"
| "pi_local"
| "cursor"
| "openclaw_gateway";
| "openclaw_gateway"
| "hermes_local";
const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType;
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent",
},
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{
value: "pi_local",
label: "Pi",

View file

@ -56,12 +56,14 @@ import {
ChevronDown,
X
} from "lucide-react";
import { HermesIcon } from "./HermesIcon";
type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "hermes_local"
| "opencode_local"
| "pi_local"
| "cursor"
@ -208,6 +210,7 @@ export function OnboardingWizard() {
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
@ -217,6 +220,8 @@ export function OnboardingWizard() {
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
@ -843,6 +848,12 @@ export function OnboardingWizard() {
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "hermes_local" as const,
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent"
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
@ -902,6 +913,7 @@ export function OnboardingWizard() {
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (

View file

@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};

View file

@ -72,6 +72,26 @@ type TranscriptBlock =
status: "running" | "completed" | "error";
}>;
}
| {
type: "tool_group";
ts: string;
endTs?: string;
items: Array<{
ts: string;
endTs?: string;
name: string;
input: unknown;
result?: string;
isError?: boolean;
status: "running" | "completed" | "error";
}>;
}
| {
type: "stderr_group";
ts: string;
endTs?: string;
lines: Array<{ ts: string; text: string }>;
}
| {
type: "stdout";
ts: string;
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
return grouped;
}
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
const grouped: TranscriptBlock[] = [];
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
let groupTs: string | null = null;
let groupEndTs: string | undefined;
const flush = () => {
if (pending.length === 0 || !groupTs) return;
grouped.push({
type: "tool_group",
ts: groupTs,
endTs: groupEndTs,
items: pending,
});
pending = [];
groupTs = null;
groupEndTs = undefined;
};
for (const block of blocks) {
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
if (!groupTs) groupTs = block.ts;
groupEndTs = block.endTs ?? block.ts;
pending.push({
ts: block.ts,
endTs: block.endTs,
name: block.name,
input: block.input,
result: block.result,
isError: block.isError,
status: block.status,
});
continue;
}
flush();
grouped.push(block);
}
flush();
return grouped;
}
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
const blocks: TranscriptBlock[] = [];
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
if (shouldHideNiceModeStderr(entry.text)) {
continue;
}
// Batch consecutive stderr entries into a single group
const prev = blocks[blocks.length - 1];
if (prev && prev.type === "stderr_group") {
prev.lines.push({ ts: entry.ts, text: entry.text });
prev.endTs = entry.ts;
} else {
blocks.push({
type: "event",
type: "stderr_group",
ts: entry.ts,
label: "stderr",
tone: "error",
text: entry.text,
endTs: entry.ts,
lines: [{ ts: entry.ts, text: entry.text }],
});
}
continue;
}
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
}
}
return groupCommandBlocks(blocks);
return groupToolBlocks(groupCommandBlocks(blocks));
}
function TranscriptMessageBlock({
@ -805,6 +873,139 @@ function TranscriptCommandGroup({
);
}
function TranscriptToolGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "tool_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
const hasError = block.items.some((item) => item.status === "error");
const isRunning = Boolean(runningItem);
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
const toolLabel =
uniqueNames.length === 1
? humanizeLabel(uniqueNames[0])
: `${uniqueNames.length} tools`;
const title = isRunning
? `Using ${toolLabel}`
: block.items.length === 1
? `Used ${toolLabel}`
: `Used ${toolLabel} (${block.items.length} calls)`;
const subtitle = runningItem
? summarizeToolInput(runningItem.name, runningItem.input, density)
: null;
const statusTone = isRunning
? "text-cyan-700 dark:text-cyan-300"
: "text-foreground/70";
return (
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
<div
role="button"
tabIndex={0}
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
const isItemRunning = item.status === "running";
const isItemError = item.status === "error";
return (
<span
key={`${item.ts}-${index}`}
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
index > 0 && "-ml-1.5",
isItemRunning
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: isItemError
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: "border-border/70 bg-background text-foreground/55",
isItemRunning && "animate-pulse",
)}
>
<Wrench className="h-3.5 w-3.5" />
</span>
);
})}
</div>
<div className="min-w-0 flex-1">
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
{title}
</div>
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
</div>
)}
</div>
<button
type="button"
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
aria-label={open ? "Collapse tool details" : "Expand tool details"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{open && (
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
{block.items.map((item, index) => (
<div key={`${item.ts}-${index}`} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
item.status === "error"
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: item.status === "running"
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: "border-border/70 bg-background text-foreground/55",
)}>
<Wrench className="h-3 w-3" />
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
{humanizeLabel(item.name)}
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
: item.status === "error" ? "text-red-700 dark:text-red-300"
: "text-emerald-700 dark:text-emerald-300"
)}>
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
</span>
</div>
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
{formatToolPayload(item.input) || "<empty>"}
</pre>
</div>
{item.result && (
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<pre className={cn(
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
)}>
{formatToolPayload(item.result)}
</pre>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TranscriptActivityRow({
block,
density,
@ -883,6 +1084,43 @@ function TranscriptEventRow({
);
}
function TranscriptStderrGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
return (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
</span>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
{block.lines.map((line, i) => (
<span key={`${line.ts}-${i}`}>
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
{line.text}
</span>
))}
</pre>
)}
</div>
);
}
function TranscriptStdoutRow({
block,
density,
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}

View file

@ -25,6 +25,8 @@ export const queryKeys = {
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
adapterModels: (companyId: string, adapterType: string) =>
["agents", companyId, "adapter-models", adapterType] as const,
detectModel: (companyId: string, adapterType: string) =>
["agents", companyId, "detect-model", adapterType] as const,
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,

View file

@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
const isLive = run.status === "running" || run.status === "queued";
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon;
const summary = run.resultJson
const summaryRaw = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
const summary = useMemo(() => {
if (!summaryRaw) return "";
const lines = summaryRaw
.replace(/^#{1,6}\s+/gm, "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
const excerpt: string[] = [];
let chars = 0;
for (const line of lines) {
if (excerpt.length >= 3 || chars + line.length > 280) break;
excerpt.push(line);
chars += line.length;
}
return excerpt.join(" ");
}, [summaryRaw]);
return (
<div className="space-y-3">
<div className="flex w-full items-center justify-between">
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
const lastSavedSkillsRef = useRef<string[]>([]);
const hasHydratedSkillSnapshotRef = useRef(false);
const skipNextSkillAutosaveRef = useRef(true);
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
{unmanagedSkillRows.length > 0 && (
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
onClick={() => setUnmanagedOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
>
<span className="text-xs font-medium text-muted-foreground">
User-installed skills, not managed by Paperclip
({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
</span>
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
{unmanagedSkillRows.map(renderSkillRow)}
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
</section>
)}
</>

View file

@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",

View file

@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
pi_local: "Pi (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
function dateTime(value: string) {
return new Date(value).toLocaleString();

View file

@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
"opencode_local",
"pi_local",
"cursor",
"hermes_local",
"openclaw_gateway",
]);

View file

@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",