Merge branch 'humanlayer:main' into main

This commit is contained in:
Steve Morin
2025-08-19 10:29:03 -07:00
committed by GitHub
16 changed files with 481 additions and 208 deletions

View File

@@ -0,0 +1,37 @@
2. set up worktree for implementation:
2a. read `hack/create_worktree.sh` and create a new worktree with the Linear branch name: `./hack/create_worktree.sh ENG-XXXX BRANCH_NAME`
3. determine required data:
branch name
path to plan file (use relative path only)
launch prompt
command to run
**IMPORTANT PATH USAGE:**
- The thoughts/ directory is synced between the main repo and worktrees
- Always use ONLY the relative path starting with `thoughts/shared/...` without any directory prefix
- Example: `thoughts/shared/plans/fix-mcp-keepalive-proper.md` (not the full absolute path)
- This works because thoughts are synced and accessible from the worktree
3a. confirm with the user by sending a message to the Human
```
based on the input, I plan to create a worktree with the following details:
worktree path: ~/wt/humanlayer/ENG-XXXX
branch name: BRANCH_NAME
path to plan file: $FILEPATH
launch prompt:
/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link
command to run:
humanlayer launch --model opus -w ~/wt/humanlayer/ENG-XXXX "/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link"
```
incorporate any user feedback then:
4. launch implementation session: `humanlayer launch --model opus -w ~/wt/humanlayer/ENG-XXXX "/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link"`

View File

@@ -17,6 +17,14 @@ make codelayer-dev
When the Web UI launches in dev mode, you'll need to launch a managed daemon with it - click the 🐞 icon in the bottom right and launch a managed daemon.
## Commands cheat sheet
1. `/research_codebase`
2. `/create_plan`
3. `/implement_plan`
4. `/commit`
5. `gh pr create --fill`
6. `/describe_pr`
## Running Tests

140
README.md
View File

@@ -4,22 +4,19 @@
</div>
**HumanLayer** is an API and SDK that enables AI Agents to contact humans for help, feedback, and approvals.
Bring your LLM (OpenAI, Llama, Claude, etc) and Framework (LangChain, CrewAI, etc) and start giving your AI agents safe access to the world.
🚧 **HumanLayer** is undergoing some changes...stay tuned! 🚧
<div align="center">
<h3>
[Homepage](https://www.humanlayer.dev/) | [Get Started](https://humanlayer.dev/docs/quickstart-python) | [Discord](https://humanlayer.dev/discord)
[HumanLayer Code](https://humanlayer.dev/code) | [Discord](https://humanlayer.dev/discord) | [Release](https://github.com/humanlayer/humanlayer/releases)
</h3>
[![GitHub Repo stars](https://img.shields.io/github/stars/humanlayer/humanlayer)](https://github.com/humanlayer/humanlayer)
[![License: Apache-2](https://img.shields.io/badge/License-Apache-green.svg)](https://opensource.org/licenses/Apache-2)
[![PyPi Version](https://img.shields.io/pypi/v/humanlayer?color=006dad)](https://pypi.org/project/humanlayer/)
[![NPM Version](https://img.shields.io/npm/v/humanlayer?color=ea2039)](https://www.npmjs.com/package/humanlayer)
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=fcfc0926-d841-47fb-b8a6-6aba3a6c3228" />
@@ -35,88 +32,6 @@ Bring your LLM (OpenAI, Llama, Claude, etc) and Framework (LangChain, CrewAI, et
- [Contributing](#contributing)
- [License](#license)
## Getting Started
To get started, check out [Getting Started](https://humanlayer.dev/docs/quickstart-python), watch the [Getting Started Video](https://www.loom.com/share/7c65d48d18d1421a864a1591ff37e2bf), or jump straight into one of the [Examples](./examples/):
- 🦜⛓️ [LangChain](./examples/langchain/)
- 🚣‍ [CrewAI](./examples/crewai/)
- 🦾 [ControlFlow](./examples/controlflow/)
- 🧠 [Raw OpenAI Client](./examples/openai_client/)
<div align="center">
<a target="_blank" href="https://youtu.be/5sbN8rh_S5Q"><img width="60%" alt="video thumbnail showing editor" src="./docs/images/video-preview.png"></a>
</div>
## Example
HumanLayer supports either Python or Typescript / JS.
```shell
pip install humanlayer
```
```python
from humanlayer import HumanLayer
hl = HumanLayer()
@hl.require_approval()
def send_email(to: str, subject: str, body: str):
"""Send an email to the customer"""
...
# made up function, use whatever
# tool-calling framework you prefer
run_llm_task(
prompt="""Send an email welcoming the customer to
the platform and encouraging them to invite a team member.""",
tools=[send_email],
llm="gpt-4o"
)
```
<div align="center"><img style="width: 400px" alt="A screenshot of slack showing a human replying to the bot" src="https://www.humanlayer.dev/slack-conversation.png"></div>
For Typescript, install with npm:
```
npm install @humanlayer/sdk
```
More python and TS examples in the [framework specific examples](./examples) or the [Getting Started Guides](https://humanlayer.dev/docs/frameworks) to get hands on.
#### Human as Tool
You can also use `hl.human_as_tool()` to bring a human into the loop for any reason. This can be useful for debugging, asking for advice, or just getting a human's opinion on something.
```python
# human_as_tool.py
from humanlayer import HumanLayer
hl = HumanLayer()
contact_a_human = hl.human_as_tool()
def send_email(to: str, subject: str, body: str):
"""Send an email to the customer"""
...
# made up method, use whatever
# framework you prefer
run_llm_task(
prompt="""Send an email welcoming the customer to
the platform and encouraging them to invite a team member.
Contact a human for collaboration and feedback on your email
draft
""",
tools=[send_email, contact_a_human],
llm="gpt-4o"
)
```
See the [examples](./examples) for more advanced human as tool examples, and workflows that combine both concepts.
## Why HumanLayer?
Functions and tools are a key part of [Agentic Workflows](https://www.deeplearning.ai/the-batch/how-agents-can-improve-llm-performance). They enable LLMs to interact meaningfully with the outside world and automate broad scopes of impactful work. Correct and accurate function calling is essential for AI agents that do meaningful things like book appointments, interact with customers, manage billing information, write+execute code, and more.
@@ -173,48 +88,6 @@ While early versions of these agents may technically be "human initiated" in tha
Example use cases for these outer loop agents include [the linkedin inbox assistant](./examples/langchain/04-human_as_tool_linkedin.py) and [the customer onboarding assistant](./examples/langchain/05-approvals_and_humans_composite.py), but that's really just scratching the surface.
## Key Features
- **Require Human Approval for Function Calls**: the `@hl.require_approval()` decorator blocks specific function calls until a human has been consulted - upon denial, feedback will be passed to the LLM
- **Human as Tool**: generic `hl.human_as_tool()` allows for contacting a human for answers, advice, or feedback
- **OmniChannel Contact**: Contact humans and collect responses across Slack, Email, Discord, and more
- **Granular Routing**: Route approvals to specific teams or individuals
- **Bring your own LLM + Framework**: Because HumanLayer is implemented at tools layer, it supports any LLM and all major orchestration frameworks that support tool calling.
## Examples
You can test different real life examples of HumanLayer in the [examples folder](./examples/):
- 🦜⛓️ [LangChain Math](./examples/langchain/01-math_example.py)
- 🦜⛓️ [LangChain Human As Tool](./examples/langchain/03-human_as_tool.py)
- 🚣‍ [CrewAI Math](./examples/crewai/crewai_math.py)
- 🦾 [ControlFlow Math](./examples/controlflow/controlflow_math.py)
- 🧠 [Raw OpenAI Client](./examples/openai_client/01-math_example.py)
## Roadmap
| Feature | Status |
| ---------------------------------------------------------------------------------- | ------------------- |
| Require Approval | ⚙️ Beta |
| Human as Tool | ⚙️ Beta |
| CLI Approvals | ⚙️ Beta |
| CLI Human as Tool | ⚙️ Beta |
| Slack Approvals | ⚙️ Beta |
| Langchain Support | ⚙️ Beta |
| CrewAI Support | ⚙️ Beta |
| [GripTape Support](./examples/griptape) | ⚗️ Alpha |
| [GripTape Builtin Tools Support](./examples/griptape/02-decorate-existing-tool.py) | 🗓️ Planned |
| Controlflow Support | ⚗️ Alpha |
| Custom Response options | ⚗️ Alpha |
| Open Protocol for BYO server | 🗓️ Planned |
| Composite Contact Channels | 🚧 Work in progress |
| Async / Webhook support | 🗓️ Planned |
| SMS/RCS Approvals | 🗓️ Planned |
| Discord Approvals | 🗓️ Planned |
| Email Approvals | ⚙️ Beta |
| LlamaIndex Support | 🗓️ Planned |
| Haystack Support | 🗓️ Planned |
## Contributing
The HumanLayer SDK and docs are open-source and we welcome contributions in the form of issues, documentation, pull requests, and more. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
@@ -223,11 +96,6 @@ The HumanLayer SDK and docs are open-source and we welcome contributions in the
[![Star History Chart](https://api.star-history.com/svg?repos=humanlayer/humanlayer&type=Date)](https://star-history.com/#humanlayer/humanlayer&Date)
Shouts out to [@erquhart](https://github.com/erquhart) for this one
<div align="center">
<img width="360" src="https://github.com/user-attachments/assets/849a7149-daff-43a7-8ca9-427ccd0ae77c" />
</div>
## Development Conventions
@@ -244,4 +112,4 @@ We use a priority-based TODO annotation system throughout the codebase:
## License
The HumanLayer SDK in this repo is licensed under the Apache 2 License.
The HumanLayer SDK and CodeLayer sources in this repo are licensed under the Apache 2 License.

View File

@@ -29,6 +29,7 @@ type CreateApprovalRequest struct {
RunID string `json:"run_id"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
ToolUseID string `json:"tool_use_id,omitempty"`
}
// CreateApprovalResponse is the response for creating a local approval
@@ -54,10 +55,22 @@ func (h *ApprovalHandlers) HandleCreateApproval(ctx context.Context, params json
return nil, fmt.Errorf("tool_input is required")
}
// Create the approval
approvalID, err := h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput)
if err != nil {
return nil, fmt.Errorf("failed to create approval: %w", err)
// Create approval with or without tool use ID
var approvalID string
if req.ToolUseID != "" {
// Use the new method that accepts tool use ID
approval, err := h.approvals.CreateApprovalWithToolUseID(ctx, req.RunID, req.ToolName, req.ToolInput, req.ToolUseID)
if err != nil {
return nil, fmt.Errorf("failed to create approval with tool use ID: %w", err)
}
approvalID = approval.ID
} else {
// Fall back to legacy method for backward compatibility
var err error
approvalID, err = h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput)
if err != nil {
return nil, fmt.Errorf("failed to create approval: %w", err)
}
}
return &CreateApprovalResponse{

View File

@@ -344,18 +344,73 @@ func TestContinueSessionInheritance(t *testing.T) {
t.Fatalf("Failed to get child MCP servers: %v", err)
}
// Should have inherited the MCP servers
if len(childMCPServers) != len(mcpServers) {
t.Errorf("MCP servers not inherited: got %d, want %d", len(childMCPServers), len(mcpServers))
// Should have inherited the MCP servers plus injected codelayer
expectedCount := len(mcpServers) + 1 // +1 for injected codelayer
if len(childMCPServers) != expectedCount {
t.Errorf("MCP servers count mismatch: got %d, want %d", len(childMCPServers), expectedCount)
}
// Verify server details (accounting for HUMANLAYER_RUN_ID being added)
// Find and verify the injected codelayer server
var foundCodelayer bool
var codelayerIdx int
for i, server := range childMCPServers {
if server.Name != mcpServers[i].Name {
t.Errorf("MCP server %d name mismatch: got %s, want %s", i, server.Name, mcpServers[i].Name)
if server.Name == "codelayer" {
foundCodelayer = true
codelayerIdx = i
// Verify codelayer configuration
if server.Command != "hlyr" {
t.Errorf("Codelayer command mismatch: got %s, want hlyr", server.Command)
}
var args []string
if err := json.Unmarshal([]byte(server.ArgsJSON), &args); err != nil {
t.Fatalf("Failed to unmarshal codelayer args: %v", err)
}
expectedArgs := []string{"mcp", "claude_approvals"}
if len(args) != len(expectedArgs) {
t.Errorf("Codelayer args length mismatch: got %d, want %d", len(args), len(expectedArgs))
} else {
for j, arg := range args {
if arg != expectedArgs[j] {
t.Errorf("Codelayer arg[%d] mismatch: got %s, want %s", j, arg, expectedArgs[j])
}
}
}
var env map[string]string
if err := json.Unmarshal([]byte(server.EnvJSON), &env); err != nil {
t.Fatalf("Failed to unmarshal codelayer env: %v", err)
}
if env["HUMANLAYER_SESSION_ID"] != childSession.ID {
t.Errorf("Codelayer env HUMANLAYER_SESSION_ID mismatch: got %s, want %s", env["HUMANLAYER_SESSION_ID"], childSession.ID)
}
break
}
if server.Command != mcpServers[i].Command {
t.Errorf("MCP server %d command mismatch: got %s, want %s", i, server.Command, mcpServers[i].Command)
}
if !foundCodelayer {
t.Error("Injected codelayer MCP server not found")
}
// Verify inherited servers (excluding codelayer)
parentIdx := 0
for i, server := range childMCPServers {
// Skip the codelayer server
if i == codelayerIdx {
continue
}
if parentIdx >= len(mcpServers) {
t.Errorf("Extra unexpected MCP server found: %s", server.Name)
continue
}
if server.Name != mcpServers[parentIdx].Name {
t.Errorf("MCP server %d name mismatch: got %s, want %s", parentIdx, server.Name, mcpServers[parentIdx].Name)
}
if server.Command != mcpServers[parentIdx].Command {
t.Errorf("MCP server %d command mismatch: got %s, want %s", parentIdx, server.Command, mcpServers[parentIdx].Command)
}
// Compare args (deserialize to compare content)
@@ -363,15 +418,15 @@ func TestContinueSessionInheritance(t *testing.T) {
if err := json.Unmarshal([]byte(server.ArgsJSON), &childArgs); err != nil {
t.Fatalf("Failed to unmarshal child args: %v", err)
}
if err := json.Unmarshal([]byte(mcpServers[i].ArgsJSON), &parentArgs); err != nil {
if err := json.Unmarshal([]byte(mcpServers[parentIdx].ArgsJSON), &parentArgs); err != nil {
t.Fatalf("Failed to unmarshal parent args: %v", err)
}
if len(childArgs) != len(parentArgs) {
t.Errorf("MCP server %d args length mismatch", i)
t.Errorf("MCP server %d args length mismatch", parentIdx)
} else {
for j, arg := range childArgs {
if arg != parentArgs[j] {
t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", i, j, arg, parentArgs[j])
t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", parentIdx, j, arg, parentArgs[j])
}
}
}
@@ -381,21 +436,23 @@ func TestContinueSessionInheritance(t *testing.T) {
if err := json.Unmarshal([]byte(server.EnvJSON), &childEnv); err != nil {
t.Fatalf("Failed to unmarshal child env: %v", err)
}
if err := json.Unmarshal([]byte(mcpServers[i].EnvJSON), &parentEnv); err != nil {
if err := json.Unmarshal([]byte(mcpServers[parentIdx].EnvJSON), &parentEnv); err != nil {
t.Fatalf("Failed to unmarshal parent env: %v", err)
}
// Child should have all parent env vars plus HUMANLAYER_RUN_ID
for key, val := range parentEnv {
if childEnv[key] != val {
t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", i, key, childEnv[key], val)
t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", parentIdx, key, childEnv[key], val)
}
}
// Should have HUMANLAYER_RUN_ID added
if _, ok := childEnv["HUMANLAYER_RUN_ID"]; !ok {
t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", i)
t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", parentIdx)
}
parentIdx++
}
})
@@ -576,17 +633,26 @@ func TestContinueSessionInheritance(t *testing.T) {
t.Fatalf("Failed to get child MCP servers: %v", err)
}
// Should have the override server, not the original
if len(childMCPServers) != 1 {
t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers))
// Should have the override server plus injected codelayer
if len(childMCPServers) != 2 {
t.Fatalf("Expected 2 MCP servers (override + codelayer), got %d", len(childMCPServers))
}
server := childMCPServers[0]
if server.Name != "override-server" {
t.Errorf("MCP server name not overridden: got %s", server.Name)
// Find the override server (not codelayer)
var overrideServer *store.MCPServer
for _, s := range childMCPServers {
if s.Name == "override-server" {
overrideServer = &s
break
}
}
if server.Command != "override-cmd" {
t.Errorf("MCP server command not overridden: got %s", server.Command)
if overrideServer == nil {
t.Fatal("Override server not found")
}
if overrideServer.Command != "override-cmd" {
t.Errorf("MCP server command not overridden: got %s", overrideServer.Command)
}
})
@@ -853,17 +919,25 @@ func TestContinueSessionInheritance(t *testing.T) {
t.Fatalf("Failed to get child MCP servers: %v", err)
}
// Should have inherited the MCP server
if len(childMCPServers) != 1 {
t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers))
// Should have inherited the MCP server plus injected codelayer
if len(childMCPServers) != 2 {
t.Fatalf("Expected 2 MCP servers (http + codelayer), got %d", len(childMCPServers))
}
childMCPServer := childMCPServers[0]
// Find the http test server (not codelayer)
var childMCPServer *store.MCPServer
for _, s := range childMCPServers {
if s.Name == "http-test-server" {
childMCPServer = &s
break
}
}
if childMCPServer == nil {
t.Fatal("HTTP test server not found")
}
// Verify basic inheritance
if childMCPServer.Name != "http-test-server" {
t.Errorf("MCP server name not inherited: got %s, want http-test-server", childMCPServer.Name)
}
if childMCPServer.Command != "http" {
t.Errorf("MCP server type not inherited: got %s, want http", childMCPServer.Command)
}

View File

@@ -68,6 +68,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig)
// Extract the Claude config (without daemon-level settings)
claudeConfig := config.SessionConfig
// Inject daemon's CodeLayer MCP server configuration
if claudeConfig.MCPConfig == nil {
claudeConfig.MCPConfig = &claudecode.MCPConfig{
MCPServers: make(map[string]claudecode.MCPServer),
}
}
// Always inject codelayer MCP server (overwrite if exists)
claudeConfig.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{
Command: "hlyr",
Args: []string{"mcp", "claude_approvals"},
Env: map[string]string{
"HUMANLAYER_SESSION_ID": sessionID,
"HUMANLAYER_DAEMON_SOCKET": m.socketPath,
},
}
slog.Debug("injected codelayer MCP server",
"session_id", sessionID,
"socket_path", m.socketPath)
// Add HUMANLAYER_RUN_ID and HUMANLAYER_DAEMON_SOCKET to MCP server environment
// For HTTP servers, inject session ID header
if claudeConfig.MCPConfig != nil {
@@ -1300,8 +1320,33 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
// Add run_id and daemon socket to MCP server environments
// For HTTP servers, inject session ID header
// Ensure MCP config exists for injection
if config.MCPConfig == nil {
config.MCPConfig = &claudecode.MCPConfig{
MCPServers: make(map[string]claudecode.MCPServer),
}
}
// Always update codelayer MCP server with child session ID
config.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{
Command: "hlyr",
Args: []string{"mcp", "claude_approvals"},
Env: map[string]string{
"HUMANLAYER_SESSION_ID": sessionID, // Use child session ID
"HUMANLAYER_DAEMON_SOCKET": m.socketPath,
},
}
slog.Debug("updated codelayer MCP server for child session",
"session_id", sessionID,
"parent_session_id", req.ParentSessionID,
"socket_path", m.socketPath)
if config.MCPConfig != nil {
for name, server := range config.MCPConfig.MCPServers {
// Skip codelayer as we already configured it above
if name == "codelayer" {
continue
}
// Check if this is an HTTP MCP server
if server.Type == "http" {
// For HTTP servers, always set session ID header to child session ID

View File

@@ -337,11 +337,25 @@ func TestLaunchSession_SetsMCPEnvironment(t *testing.T) {
}
// Verify MCP servers have the correct environment variables
if len(capturedMCPServers) != 1 {
t.Fatalf("Expected 1 MCP server, got %d", len(capturedMCPServers))
// Should have the test server plus injected codelayer
if len(capturedMCPServers) != 2 {
t.Fatalf("Expected 2 MCP servers (test + codelayer), got %d", len(capturedMCPServers))
}
server := capturedMCPServers[0]
// Find the test server (not codelayer)
var server store.MCPServer
var found bool
for _, s := range capturedMCPServers {
if s.Name == "test-server" {
server = s
found = true
break
}
}
if !found {
t.Fatal("Test server not found in MCP servers")
}
// Parse the environment JSON
var env map[string]string

View File

@@ -45,22 +45,6 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
const client = await connectWithRetry(socketPath, 3, 1000)
try {
// Build MCP config (approvals enabled by default unless explicitly disabled)
// Phase 6: Using HTTP MCP endpoint instead of stdio
const daemonPort = process.env.HUMANLAYER_DAEMON_HTTP_PORT || '7777'
const mcpConfig =
options.approvals !== false
? {
mcpServers: {
codelayer: {
type: 'http',
url: `http://localhost:${daemonPort}/api/v1/mcp`,
// Session ID will be added as header by Claude Code
},
},
}
: undefined
// Launch the session
const result = await client.launchSession({
query: query,
@@ -68,8 +52,9 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
model: options.model,
working_dir: options.workingDir || process.cwd(),
max_turns: options.maxTurns,
mcp_config: mcpConfig,
permission_prompt_tool: mcpConfig ? 'mcp__codelayer__request_approval' : undefined,
// MCP config is now injected by daemon
permission_prompt_tool:
options.approvals !== false ? 'mcp__codelayer__request_permission' : undefined,
dangerously_skip_permissions: options.dangerouslySkipPermissions,
dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout
? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000

View File

@@ -353,11 +353,13 @@ export class DaemonClient extends EventEmitter {
runId: string,
toolName: string,
toolInput: unknown,
toolUseId?: string,
): Promise<{ approval_id: string }> {
return this.call<{ approval_id: string }>('createApproval', {
run_id: runId,
tool_name: toolName,
tool_input: toolInput,
...(toolUseId && { tool_use_id: toolUseId }),
})
}

View File

@@ -10,6 +10,7 @@ import { launchCommand } from './commands/launch.js'
import { alertCommand } from './commands/alert.js'
import { thoughtsCommand } from './commands/thoughts.js'
import { joinWaitlistCommand } from './commands/joinWaitlist.js'
import { startClaudeApprovalsMCPServer } from './mcp.js'
import {
getDefaultConfigPath,
resolveFullConfig,
@@ -66,7 +67,7 @@ async function authenticate(printSelectedProject: boolean = false) {
program.name('humanlayer').description('HumanLayer, but on your command-line.').version(VERSION)
const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch']
const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch', 'mcp']
program.hook('preAction', async (thisCmd, actionCmd) => {
// Get the full command path by traversing up the command hierarchy
@@ -96,6 +97,13 @@ program
.option('--config-file <path>', 'Path to config file')
.action(loginCommand)
const mcpCommand = program.command('mcp').description('MCP server functionality')
mcpCommand
.command('claude_approvals')
.description('Start the Claude approvals MCP server for permission requests')
.action(startClaudeApprovalsMCPServer)
program
.command('launch <query>')
.description('Launch a new Claude Code session via the daemon')

192
hlyr/src/mcp.ts Normal file
View File

@@ -0,0 +1,192 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js'
import { resolveFullConfig } from './config.js'
import { DaemonClient } from './daemonClient.js'
import { logger } from './mcpLogger.js'
/**
* Start the Claude approvals MCP server that provides request_permission functionality
* Returns responses in the format required by Claude Code SDK
*
* This uses local approvals through the daemon instead of HumanLayer API
*/
export async function startClaudeApprovalsMCPServer() {
// No auth validation needed - uses local daemon
logger.info('Starting Claude approvals MCP server')
logger.info('Environment variables', {
HUMANLAYER_DAEMON_SOCKET: process.env.HUMANLAYER_DAEMON_SOCKET,
HUMANLAYER_SESSION_ID: process.env.HUMANLAYER_SESSION_ID,
})
const server = new Server(
{
name: 'humanlayer-claude-local-approvals',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
},
)
// Create daemon client with socket path from environment or config
// The daemon sets HUMANLAYER_DAEMON_SOCKET for MCP servers it launches
const resolvedConfig = resolveFullConfig({})
const socketPath = process.env.HUMANLAYER_DAEMON_SOCKET || resolvedConfig.daemon_socket
logger.info('Creating daemon client', { socketPath })
const daemonClient = new DaemonClient(socketPath)
server.setRequestHandler(ListToolsRequestSchema, async () => {
logger.info('ListTools request received')
const tools = [
{
name: 'request_permission',
description: 'Request permission to perform an action',
inputSchema: {
type: 'object',
properties: {
tool_name: { type: 'string' },
input: { type: 'object' },
tool_use_id: { type: 'string' }, // Added for Phase 2
},
required: ['tool_name', 'input', 'tool_use_id'],
},
},
]
logger.info('Returning tools', { tools })
return { tools }
})
server.setRequestHandler(CallToolRequestSchema, async request => {
logger.debug('Received tool call request', { name: request.params.name })
if (request.params.name === 'request_permission') {
const toolName: string | undefined = request.params.arguments?.tool_name
const toolUseId: string | undefined = request.params.arguments?.tool_use_id // Phase 2
if (!toolName) {
logger.error('Invalid tool name in request_permission', request.params.arguments)
throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name requesting permissions')
}
const input: Record<string, unknown> = request.params.arguments?.input || {}
// Get session ID from environment (set by daemon)
const sessionId = process.env.HUMANLAYER_SESSION_ID
if (!sessionId) {
logger.error('HUMANLAYER_SESSION_ID not set in environment')
throw new McpError(ErrorCode.InternalError, 'HUMANLAYER_SESSION_ID not set')
}
logger.info('Processing approval request', { sessionId, toolName, toolUseId })
try {
// Connect to daemon
logger.debug('Connecting to daemon...')
await daemonClient.connect()
logger.debug('Connected to daemon')
// Create approval request with tool use ID (Phase 2)
logger.debug('Creating approval request...', { sessionId, toolName, toolUseId })
const createResponse = await daemonClient.createApproval(sessionId, toolName, input, toolUseId)
const approvalId = createResponse.approval_id
logger.info('Created approval', { approvalId })
// Poll for approval status
let approved = false
let comment = ''
let polling = true
while (polling) {
try {
// Get the specific approval by ID
logger.debug('Fetching approval status...', { approvalId })
const approval = (await daemonClient.getApproval(approvalId)) as {
id: string
status: string
comment?: string
}
logger.debug('Approval status', { status: approval.status })
if (approval.status !== 'pending') {
// Approval has been resolved
approved = approval.status === 'approved'
comment = approval.comment || ''
polling = false
logger.info('Approval resolved', {
approvalId,
status: approval.status,
approved,
})
} else {
// Still pending, wait and poll again
logger.debug('Approval still pending, polling again...')
await new Promise(resolve => setTimeout(resolve, 1000))
}
} catch (error) {
logger.error('Failed to get approval status', { error, approvalId })
// Re-throw the error since this is a critical failure
throw new McpError(ErrorCode.InternalError, 'Failed to get approval status')
}
}
if (!approved) {
logger.info('Approval denied', { approvalId, comment })
return {
content: [
{
type: 'text',
text: JSON.stringify({
behavior: 'deny',
message: comment || 'Request denied by human reviewer',
}),
},
],
}
}
logger.info('Approval granted', { approvalId })
return {
content: [
{
type: 'text',
text: JSON.stringify({
behavior: 'allow',
updatedInput: input,
}),
},
],
}
} catch (error) {
logger.error('Failed to process approval', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to process approval: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
logger.debug('Closing daemon connection')
daemonClient.close()
}
}
throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name')
})
const transport = new StdioServerTransport()
try {
await server.connect(transport)
logger.info('MCP server connected and ready')
} catch (error) {
logger.error('Failed to start MCP server', error)
throw error
}
}

View File

@@ -118,8 +118,14 @@ export function DebugPanel({ open, onOpenChange }: DebugPanelProps) {
async function handleConnectToCustom() {
setConnectError(null)
let url = customUrl.trim()
if (!isNaN(Number(url))) {
url = `http://127.0.0.1:${url}`
}
try {
await daemonService.connectToExisting(customUrl)
await daemonService.connectToExisting(url)
await reconnect()
await loadDaemonInfo()
setCustomUrl('')
@@ -221,7 +227,7 @@ export function DebugPanel({ open, onOpenChange }: DebugPanelProps) {
<CardHeader>
<CardTitle className="text-sm">Connect to Existing Daemon</CardTitle>
<CardDescription className="text-xs">
Connect to a daemon running on a custom URL
Connect to a daemon running on a custom URL (or provide a port number).
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">

View File

@@ -296,6 +296,20 @@ export function Layout() {
}
})
// Prevent escape key from exiting full screen
// Might be worth guarding this specifically in macOS
// down-the-road
useHotkeys(
'escape',
() => {
// console.log('escape!', ev);
},
{
enableOnFormTags: true,
preventDefault: true,
},
)
// Load sessions when connected
useEffect(() => {
if (connected) {

View File

@@ -147,7 +147,10 @@ export function useSessionActions({
interruptSession(session.id)
}
},
{ scopes: SessionDetailHotkeysScope },
{
scopes: SessionDetailHotkeysScope,
enableOnFormTags: true,
},
)
// R key - no longer needed since input is always visible

View File

@@ -1,7 +1,6 @@
import { create } from 'zustand'
import { daemonClient } from '@/lib/daemon'
import type { LaunchSessionRequest } from '@/lib/daemon/types'
import { getDaemonUrl } from '@/lib/daemon/http-config'
import { useHotkeysContext } from 'react-hotkeys-hook'
import { SessionTableHotkeysScope } from '@/components/internal/SessionTable'
import { exists } from '@tauri-apps/plugin-fs'
@@ -141,17 +140,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
try {
set({ isLaunching: true, error: undefined })
// Build MCP config (approvals enabled by default)
// Use HTTP-based MCP server built into the daemon
const daemonUrl = await getDaemonUrl()
const mcpConfig = {
mcpServers: {
approvals: {
type: 'http',
url: `${daemonUrl}/api/v1/mcp`,
},
},
}
// MCP config is now injected by daemon
const request: LaunchSessionRequest = {
query: query.trim(),
@@ -159,8 +148,8 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
working_dir: config.workingDir || undefined,
model: config.model || undefined,
max_turns: config.maxTurns || undefined,
mcp_config: mcpConfig,
permission_prompt_tool: 'mcp__approvals__request_approval',
// MCP config is now injected by daemon
permission_prompt_tool: 'mcp__codelayer__request_permission',
}
const response = await daemonClient.launchSession(request)

View File

@@ -102,6 +102,21 @@
/* Custom bullets for unordered lists */
.prose-terminal ul {
list-style: none;
/* This is slightly a hack, we're making heavy use of `pre-wrap`
and we can potentially remove this in the future. For now it helps
make sure we're not rendering extra space around list items */
white-space: normal;
display: flex;
flex-direction: column;
gap: 0.75em;
}
.prose-terminal ul > li {
display: flex;
flex-direction: column;
gap: 0.75em;
}
.prose-terminal ul > li::before {