Compare commits
17 commits
PAP-878-cr
...
docs/maint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
627a4d7995 | ||
|
|
f1b6d33f0a | ||
|
|
bec5305301 | ||
|
|
a07e4e5ca5 | ||
|
|
0cc0320ac1 | ||
|
|
68171c4797 | ||
|
|
6a72faf83b | ||
|
|
1fd40920db | ||
|
|
caef115b95 | ||
|
|
17e5322e28 | ||
|
|
582f4ceaf4 | ||
|
|
1583a2d65a | ||
|
|
9a70a4edaa | ||
|
|
0ac01a04e5 | ||
|
|
11ff24cd22 | ||
|
|
a5d47166e2 | ||
|
|
af5b980362 |
36 changed files with 815 additions and 57 deletions
1
.doc-review-cursor
Normal file
1
.doc-review-cursor
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
6a72faf83b3aeb35ee44da51898fe8cbce6f5002
|
||||||
12
AGENTS.md
12
AGENTS.md
|
|
@ -24,12 +24,22 @@ Before making changes, read in this order:
|
||||||
|
|
||||||
- `server/`: Express REST API and orchestration services
|
- `server/`: Express REST API and orchestration services
|
||||||
- `ui/`: React + Vite board UI
|
- `ui/`: React + Vite board UI
|
||||||
|
- `cli/`: CLI package (`paperclipai` command)
|
||||||
- `packages/db/`: Drizzle schema, migrations, DB clients
|
- `packages/db/`: Drizzle schema, migrations, DB clients
|
||||||
- `packages/shared/`: shared types, constants, validators, API path constants
|
- `packages/shared/`: shared types, constants, validators, API path constants
|
||||||
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
|
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
|
||||||
- `packages/adapter-utils/`: shared adapter utilities
|
- `packages/adapter-utils/`: shared adapter utilities
|
||||||
- `packages/plugins/`: plugin system packages
|
- `packages/plugins/`: plugin system packages
|
||||||
|
- `skills/`: agent skills (Paperclip skill, company skills)
|
||||||
- `doc/`: operational and product docs
|
- `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)
|
## 4. Dev Setup (Auto DB)
|
||||||
|
|
||||||
|
|
@ -55,7 +65,7 @@ curl http://localhost:3100/api/companies
|
||||||
Reset local dev DB:
|
Reset local dev DB:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rm -rf data/pglite
|
rm -rf ~/.paperclip/instances/default/db
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ We really appreciate both small fixes and thoughtful larger changes.
|
||||||
- Pick **one** clear thing to fix/improve
|
- Pick **one** clear thing to fix/improve
|
||||||
- Touch the **smallest possible number of files**
|
- Touch the **smallest possible number of files**
|
||||||
- Make sure the change is very targeted and easy to review
|
- Make sure the change is very targeted and easy to review
|
||||||
- All automated checks pass (including Greptile comments)
|
- All automated checks pass (CI typecheck, tests, build)
|
||||||
- No new lint/test failures
|
- No new test failures
|
||||||
|
|
||||||
These almost always get merged quickly when they're clean.
|
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
|
- Clear description of what & why
|
||||||
- Proof it works (manual testing notes)
|
- Proof it works (manual testing notes)
|
||||||
- All tests passing
|
- 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.
|
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||||
- ⚪ Artifacts & Deployments
|
- ⚪ Artifacts & Deployments
|
||||||
- ⚪ CEO Chat
|
- ⚪ CEO Chat
|
||||||
- ⚪ MAXIMIZER MODE
|
- ⚪ MAXIMIZER MODE
|
||||||
- ⚪ Multiple Human Users
|
- ✅ Multiple Human Users
|
||||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||||
- ⚪ Cloud deployments
|
- ⚪ Cloud deployments
|
||||||
- ⚪ Desktop App
|
- ⚪ Desktop App
|
||||||
|
|
|
||||||
|
|
@ -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?"
|
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
|
## Principles
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ Agent configuration includes an **adapter** that defines how Paperclip invokes t
|
||||||
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
||||||
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
||||||
| `pi_local` | Local Pi process | Pi CLI 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 |
|
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
||||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||||
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
| `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
|
- Well-defined API boundaries that plugins can hook into
|
||||||
- Event system or hooks for reacting to task/Agent lifecycle events
|
- Event system or hooks for reacting to task/Agent lifecycle events
|
||||||
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,12 @@ When a heartbeat fires, Paperclip:
|
||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex 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`) |
|
| 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 |
|
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||||
|
|
||||||
|
|
@ -55,7 +58,7 @@ Three registries consume these modules:
|
||||||
|
|
||||||
## Choosing an Adapter
|
## 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 run a script or command?** Use `process`
|
||||||
- **Need to call an external service?** Use `http`
|
- **Need to call an external service?** Use `http`
|
||||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ Built-in adapters:
|
||||||
- `claude_local`: runs your local `claude` CLI
|
- `claude_local`: runs your local `claude` CLI
|
||||||
- `codex_local`: runs your local `codex` CLI
|
- `codex_local`: runs your local `codex` CLI
|
||||||
- `opencode_local`: runs your local `opencode` CLI
|
- `opencode_local`: runs your local `opencode` CLI
|
||||||
|
- `gemini_local`: runs your local Gemini CLI
|
||||||
- `hermes_local`: runs your local `hermes` CLI
|
- `hermes_local`: runs your local `hermes` CLI
|
||||||
- `cursor`: runs Cursor in background mode
|
- `cursor`: runs Cursor in background mode
|
||||||
- `pi_local`: runs an embedded Pi agent locally
|
- `pi_local`: runs an embedded Pi agent locally
|
||||||
|
|
@ -44,7 +45,7 @@ Built-in adapters:
|
||||||
- `process`: generic shell command adapter
|
- `process`: generic shell command adapter
|
||||||
- `http`: calls an external HTTP endpoint
|
- `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
|
## 3.2 Runtime behavior
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"guides/board-operator/managing-agents",
|
"guides/board-operator/managing-agents",
|
||||||
"guides/board-operator/org-structure",
|
"guides/board-operator/org-structure",
|
||||||
"guides/board-operator/managing-tasks",
|
"guides/board-operator/managing-tasks",
|
||||||
|
"guides/board-operator/delegation",
|
||||||
"guides/board-operator/approvals",
|
"guides/board-operator/approvals",
|
||||||
"guides/board-operator/costs-and-budgets",
|
"guides/board-operator/costs-and-budgets",
|
||||||
"guides/board-operator/activity-log",
|
"guides/board-operator/activity-log",
|
||||||
|
|
|
||||||
122
docs/guides/board-operator/delegation.md
Normal file
122
docs/guides/board-operator/delegation.md
Normal 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
|
||||||
|
|
@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
|
||||||
|
|
||||||
Common adapter choices:
|
Common adapter choices:
|
||||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
- `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
|
- `process` for generic local command execution
|
||||||
|
|
||||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
---
|
---
|
||||||
title: Core Concepts
|
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
|
## 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`.
|
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
|
## Heartbeats
|
||||||
|
|
||||||
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.
|
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,12 @@ export interface ServerAdapterModule {
|
||||||
* without knowing provider-specific credential paths or API shapes.
|
* without knowing provider-specific credential paths or API shapes.
|
||||||
*/
|
*/
|
||||||
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
|
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
21
pnpm-lock.yaml
generated
|
|
@ -504,8 +504,8 @@ importers:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
hermes-paperclip-adapter:
|
hermes-paperclip-adapter:
|
||||||
specifier: 0.1.1
|
specifier: ^0.2.0
|
||||||
version: 0.1.1
|
version: 0.2.0
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^28.1.0
|
specifier: ^28.1.0
|
||||||
version: 28.1.0(@noble/hashes@2.0.1)
|
version: 28.1.0(@noble/hashes@2.0.1)
|
||||||
|
|
@ -639,6 +639,9 @@ importers:
|
||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
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)
|
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:
|
lexical:
|
||||||
specifier: 0.35.0
|
specifier: 0.35.0
|
||||||
version: 0.35.0
|
version: 0.35.0
|
||||||
|
|
@ -2040,8 +2043,8 @@ packages:
|
||||||
'@open-draft/deferred-promise@2.2.0':
|
'@open-draft/deferred-promise@2.2.0':
|
||||||
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
||||||
|
|
||||||
'@paperclipai/adapter-utils@0.3.1':
|
'@paperclipai/adapter-utils@2026.325.0':
|
||||||
resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==}
|
resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
|
||||||
|
|
||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
@ -4468,8 +4471,8 @@ packages:
|
||||||
help-me@5.0.0:
|
help-me@5.0.0:
|
||||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||||
|
|
||||||
hermes-paperclip-adapter@0.1.1:
|
hermes-paperclip-adapter@0.2.0:
|
||||||
resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==}
|
resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0:
|
html-encoding-sniffer@6.0.0:
|
||||||
|
|
@ -7740,7 +7743,7 @@ snapshots:
|
||||||
|
|
||||||
'@open-draft/deferred-promise@2.2.0': {}
|
'@open-draft/deferred-promise@2.2.0': {}
|
||||||
|
|
||||||
'@paperclipai/adapter-utils@0.3.1': {}
|
'@paperclipai/adapter-utils@2026.325.0': {}
|
||||||
|
|
||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -10337,9 +10340,9 @@ snapshots:
|
||||||
|
|
||||||
help-me@5.0.0: {}
|
help-me@5.0.0: {}
|
||||||
|
|
||||||
hermes-paperclip-adapter@0.1.1:
|
hermes-paperclip-adapter@0.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@paperclipai/adapter-utils': 0.3.1
|
'@paperclipai/adapter-utils': 2026.325.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
|
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
"embedded-postgres": "^18.1.0-beta.16",
|
"embedded-postgres": "^18.1.0-beta.16",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"hermes-paperclip-adapter": "0.1.1",
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||||
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
|
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
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 serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
|
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
|
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
|
||||||
export type {
|
export type {
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ import {
|
||||||
execute as hermesExecute,
|
execute as hermesExecute,
|
||||||
testEnvironment as hermesTestEnvironment,
|
testEnvironment as hermesTestEnvironment,
|
||||||
sessionCodec as hermesSessionCodec,
|
sessionCodec as hermesSessionCodec,
|
||||||
|
listSkills as hermesListSkills,
|
||||||
|
syncSkills as hermesSyncSkills,
|
||||||
|
detectModel as detectModelFromHermes,
|
||||||
} from "hermes-paperclip-adapter/server";
|
} from "hermes-paperclip-adapter/server";
|
||||||
import {
|
import {
|
||||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||||
|
|
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
||||||
execute: hermesExecute,
|
execute: hermesExecute,
|
||||||
testEnvironment: hermesTestEnvironment,
|
testEnvironment: hermesTestEnvironment,
|
||||||
sessionCodec: hermesSessionCodec,
|
sessionCodec: hermesSessionCodec,
|
||||||
|
listSkills: hermesListSkills,
|
||||||
|
syncSkills: hermesSyncSkills,
|
||||||
models: hermesModels,
|
models: hermesModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||||
|
detectModel: () => detectModelFromHermes(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
|
|
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
||||||
return Array.from(adaptersByType.values());
|
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 {
|
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||||
return adaptersByType.get(type) ?? null;
|
return adaptersByType.get(type) ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import {
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.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 { redactEventPayload } from "../redaction.js";
|
||||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.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);
|
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(
|
router.post(
|
||||||
"/companies/:companyId/adapters/:type/test-environment",
|
"/companies/:companyId/adapters/:type/test-environment",
|
||||||
validate(testAdapterEnvironmentSchema),
|
validate(testAdapterEnvironmentSchema),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|
|
||||||
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/hermes-local/index.ts
Normal file
12
ui/src/adapters/hermes-local/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
||||||
import { codexLocalUIAdapter } from "./codex-local";
|
import { codexLocalUIAdapter } from "./codex-local";
|
||||||
import { cursorLocalUIAdapter } from "./cursor";
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||||
|
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
|
|
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
|
||||||
claudeLocalUIAdapter,
|
claudeLocalUIAdapter,
|
||||||
codexLocalUIAdapter,
|
codexLocalUIAdapter,
|
||||||
geminiLocalUIAdapter,
|
geminiLocalUIAdapter,
|
||||||
|
hermesLocalUIAdapter,
|
||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
cursorLocalUIAdapter,
|
cursorLocalUIAdapter,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ export interface AdapterModel {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetectedAdapterModel {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaudeLoginResult {
|
export interface ClaudeLoginResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
signal: string | null;
|
signal: string | null;
|
||||||
|
|
@ -159,6 +165,10 @@ export const agentsApi = {
|
||||||
api.get<AdapterModel[]>(
|
api.get<AdapterModel[]>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
`/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: (
|
testEnvironment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
|
|
||||||
|
|
@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
}
|
}
|
||||||
if (overlay.adapterType !== undefined) {
|
if (overlay.adapterType !== undefined) {
|
||||||
patch.adapterType = overlay.adapterType;
|
patch.adapterType = overlay.adapterType;
|
||||||
// When adapter type changes, send only the new config — don't merge
|
// When adapter type changes, replace adapter-specific fields but preserve
|
||||||
// with old config since old adapter fields are meaningless for the new type
|
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
|
||||||
patch.adapterConfig = overlay.adapterConfig;
|
// 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) {
|
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||||
|
|
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor";
|
adapterType === "cursor";
|
||||||
|
const isHermesLocal = adapterType === "hermes_local";
|
||||||
const showLegacyWorkingDirectoryField =
|
const showLegacyWorkingDirectoryField =
|
||||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
enabled: Boolean(selectedCompanyId),
|
enabled: Boolean(selectedCompanyId),
|
||||||
});
|
});
|
||||||
const models = fetchedModels ?? externalModels ?? [];
|
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({
|
const { data: companyAgents = [] } = useQuery({
|
||||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||||
|
|
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "gemini_local"
|
: adapterType === "gemini_local"
|
||||||
? "gemini"
|
? "gemini"
|
||||||
|
: adapterType === "hermes_local"
|
||||||
|
? "hermes"
|
||||||
: adapterType === "pi_local"
|
: adapterType === "pi_local"
|
||||||
? "pi"
|
? "pi"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
|
|
@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
}
|
}
|
||||||
open={modelOpen}
|
open={modelOpen}
|
||||||
onOpenChange={setModelOpen}
|
onOpenChange={setModelOpen}
|
||||||
allowDefault={adapterType !== "opencode_local"}
|
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||||
required={adapterType === "opencode_local"}
|
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||||
groupByProvider={adapterType === "opencode_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 && (
|
{fetchedModelsError && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
|
|
@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- 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. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
|
|
@ -1293,6 +1339,10 @@ function ModelDropdown({
|
||||||
allowDefault,
|
allowDefault,
|
||||||
required,
|
required,
|
||||||
groupByProvider,
|
groupByProvider,
|
||||||
|
creatable,
|
||||||
|
detectedModel,
|
||||||
|
onDetectModel,
|
||||||
|
detectModelLabel,
|
||||||
}: {
|
}: {
|
||||||
models: AdapterModel[];
|
models: AdapterModel[];
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -1302,9 +1352,20 @@ function ModelDropdown({
|
||||||
allowDefault: boolean;
|
allowDefault: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
groupByProvider: boolean;
|
groupByProvider: boolean;
|
||||||
|
creatable?: boolean;
|
||||||
|
detectedModel?: string | null;
|
||||||
|
onDetectModel?: () => Promise<string | null>;
|
||||||
|
detectModelLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [modelSearch, setModelSearch] = useState("");
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
|
const [detectingModel, setDetectingModel] = useState(false);
|
||||||
const selected = models.find((m) => m.id === value);
|
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(() => {
|
const filteredModels = useMemo(() => {
|
||||||
return models.filter((m) => {
|
return models.filter((m) => {
|
||||||
if (!modelSearch.trim()) return true;
|
if (!modelSearch.trim()) return true;
|
||||||
|
|
@ -1341,6 +1402,21 @@ function ModelDropdown({
|
||||||
}));
|
}));
|
||||||
}, [filteredModels, groupByProvider]);
|
}, [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 (
|
return (
|
||||||
<Field label="Model" hint={help.model}>
|
<Field label="Model" hint={help.model}>
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -1351,7 +1427,7 @@ function ModelDropdown({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<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")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{selected
|
{selected
|
||||||
? selected.label
|
? selected.label
|
||||||
|
|
@ -1361,16 +1437,84 @@ function ModelDropdown({
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||||
<input
|
<div className="relative mb-1">
|
||||||
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"
|
<input
|
||||||
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"
|
||||||
value={modelSearch}
|
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
||||||
onChange={(e) => setModelSearch(e.target.value)}
|
value={modelSearch}
|
||||||
autoFocus
|
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">
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
{allowDefault && (
|
{allowDefault && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
!value && "bg-accent",
|
!value && "bg-accent",
|
||||||
|
|
@ -1383,6 +1527,20 @@ function ModelDropdown({
|
||||||
Default
|
Default
|
||||||
</button>
|
</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) => (
|
{groupedModels.map((group) => (
|
||||||
<div key={group.provider} className="mb-1 last:mb-0">
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
{groupByProvider && (
|
{groupByProvider && (
|
||||||
|
|
@ -1392,6 +1550,7 @@ function ModelDropdown({
|
||||||
)}
|
)}
|
||||||
{group.entries.map((m) => (
|
{group.entries.map((m) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
|
@ -1409,8 +1568,14 @@ function ModelDropdown({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredModels.length === 0 && (
|
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
<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>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
|
||||||
43
ui/src/components/HermesIcon.tsx
Normal file
43
ui/src/components/HermesIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
|
import { HermesIcon } from "./HermesIcon";
|
||||||
|
|
||||||
type AdvancedAdapterType =
|
type AdvancedAdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
|
|
@ -29,7 +30,8 @@ type AdvancedAdapterType =
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
| "openclaw_gateway";
|
| "openclaw_gateway"
|
||||||
|
| "hermes_local";
|
||||||
|
|
||||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
value: AdvancedAdapterType;
|
value: AdvancedAdapterType;
|
||||||
|
|
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
icon: OpenCodeLogoIcon,
|
icon: OpenCodeLogoIcon,
|
||||||
desc: "Local multi-provider agent",
|
desc: "Local multi-provider agent",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "hermes_local",
|
||||||
|
label: "Hermes Agent",
|
||||||
|
icon: HermesIcon,
|
||||||
|
desc: "Local multi-provider agent",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "pi_local",
|
value: "pi_local",
|
||||||
label: "Pi",
|
label: "Pi",
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,14 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HermesIcon } from "./HermesIcon";
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4;
|
type Step = 1 | 2 | 3 | 4;
|
||||||
type AdapterType =
|
type AdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
| "gemini_local"
|
| "gemini_local"
|
||||||
|
| "hermes_local"
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
|
|
@ -208,6 +210,7 @@ export function OnboardingWizard() {
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor";
|
adapterType === "cursor";
|
||||||
|
|
@ -217,6 +220,8 @@ export function OnboardingWizard() {
|
||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "gemini_local"
|
: adapterType === "gemini_local"
|
||||||
? "gemini"
|
? "gemini"
|
||||||
|
: adapterType === "hermes_local"
|
||||||
|
? "hermes"
|
||||||
: adapterType === "pi_local"
|
: adapterType === "pi_local"
|
||||||
? "pi"
|
? "pi"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
|
|
@ -843,6 +848,12 @@ export function OnboardingWizard() {
|
||||||
icon: MousePointer2,
|
icon: MousePointer2,
|
||||||
desc: "Local Cursor agent"
|
desc: "Local Cursor agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "hermes_local" as const,
|
||||||
|
label: "Hermes Agent",
|
||||||
|
icon: HermesIcon,
|
||||||
|
desc: "Local multi-provider agent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "openclaw_gateway" as const,
|
value: "openclaw_gateway" as const,
|
||||||
label: "OpenClaw Gateway",
|
label: "OpenClaw Gateway",
|
||||||
|
|
@ -902,6 +913,7 @@ export function OnboardingWizard() {
|
||||||
{(adapterType === "claude_local" ||
|
{(adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor") && (
|
adapterType === "cursor") && (
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
hermes_local: "Hermes Agent",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,26 @@ type TranscriptBlock =
|
||||||
status: "running" | "completed" | "error";
|
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";
|
type: "stdout";
|
||||||
ts: string;
|
ts: string;
|
||||||
|
|
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||||
return grouped;
|
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[] {
|
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||||
const blocks: TranscriptBlock[] = [];
|
const blocks: TranscriptBlock[] = [];
|
||||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||||
|
|
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||||
if (shouldHideNiceModeStderr(entry.text)) {
|
if (shouldHideNiceModeStderr(entry.text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
blocks.push({
|
// Batch consecutive stderr entries into a single group
|
||||||
type: "event",
|
const prev = blocks[blocks.length - 1];
|
||||||
ts: entry.ts,
|
if (prev && prev.type === "stderr_group") {
|
||||||
label: "stderr",
|
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||||
tone: "error",
|
prev.endTs = entry.ts;
|
||||||
text: entry.text,
|
} else {
|
||||||
});
|
blocks.push({
|
||||||
|
type: "stderr_group",
|
||||||
|
ts: entry.ts,
|
||||||
|
endTs: entry.ts,
|
||||||
|
lines: [{ ts: entry.ts, text: entry.text }],
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupCommandBlocks(blocks);
|
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||||
}
|
}
|
||||||
|
|
||||||
function TranscriptMessageBlock({
|
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({
|
function TranscriptActivityRow({
|
||||||
block,
|
block,
|
||||||
density,
|
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({
|
function TranscriptStdoutRow({
|
||||||
block,
|
block,
|
||||||
density,
|
density,
|
||||||
|
|
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
|
||||||
)}
|
)}
|
||||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||||
{block.type === "command_group" && <TranscriptCommandGroup 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" && (
|
{block.type === "stdout" && (
|
||||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export const queryKeys = {
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||||
adapterModels: (companyId: string, adapterType: string) =>
|
adapterModels: (companyId: string, adapterType: string) =>
|
||||||
["agents", companyId, "adapter-models", adapterType] as const,
|
["agents", companyId, "adapter-models", adapterType] as const,
|
||||||
|
detectModel: (companyId: string, adapterType: string) =>
|
||||||
|
["agents", companyId, "detect-model", adapterType] as const,
|
||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
|
|
|
||||||
|
|
@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||||
const isLive = run.status === "running" || run.status === "queued";
|
const isLive = run.status === "running" || run.status === "queued";
|
||||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||||
const StatusIcon = statusInfo.icon;
|
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 ?? "")
|
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||||
: run.error ?? "";
|
: 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
|
|
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||||
|
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
||||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||||
const skipNextSkillAutosaveRef = useRef(true);
|
const skipNextSkillAutosaveRef = useRef(true);
|
||||||
|
|
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
|
||||||
|
|
||||||
{unmanagedSkillRows.length > 0 && (
|
{unmanagedSkillRows.length > 0 && (
|
||||||
<section className="border-y border-border">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
{unmanagedSkillRows.map(renderSkillRow)}
|
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
||||||
gemini_local: "Gemini",
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
|
hermes_local: "Hermes",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
|
||||||
pi_local: "Pi (local)",
|
pi_local: "Pi (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
hermes_local: "Hermes Agent",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
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) {
|
function dateTime(value: string) {
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
|
"hermes_local",
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
|
||||||
gemini_local: "Gemini",
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
|
hermes_local: "Hermes",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue