Merge remote-tracking branch 'upstream' into ENG-1874

This commit is contained in:
Sundeep Malladi
2025-08-15 07:59:17 -05:00
31 changed files with 672 additions and 211 deletions

View File

@@ -69,7 +69,10 @@ func (c *Client) buildArgs(config SessionConfig) ([]string, error) {
args := []string{}
// Always use print mode for SDK
args = append(args, "--print", config.Query)
args = append(args, "--print")
// Title is stored in our database but not passed to Claude CLI
// (Claude doesn't support --title flag)
// Session management
if config.SessionID != "" {
@@ -150,6 +153,11 @@ func (c *Client) buildArgs(config SessionConfig) ([]string, error) {
args = append(args, "--verbose")
}
// Query must be passed as a positional argument at the end
if config.Query != "" {
args = append(args, config.Query)
}
return args, nil
}
@@ -336,17 +344,18 @@ func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
// Store result if this is the final message
if event.Type == "result" {
s.result = &Result{
Type: event.Type,
Subtype: event.Subtype,
CostUSD: event.CostUSD,
IsError: event.IsError,
DurationMS: event.DurationMS,
DurationAPI: event.DurationAPI,
NumTurns: event.NumTurns,
Result: event.Result,
SessionID: event.SessionID,
Usage: event.Usage,
Error: event.Error,
Type: event.Type,
Subtype: event.Subtype,
CostUSD: event.CostUSD,
IsError: event.IsError,
DurationMS: event.DurationMS,
DurationAPI: event.DurationAPI,
NumTurns: event.NumTurns,
Result: event.Result,
SessionID: event.SessionID,
Usage: event.Usage,
Error: event.Error,
PermissionDenials: event.PermissionDenials,
}
}

View File

@@ -492,3 +492,41 @@ func TestClaudeCodeSchemaCompatibility(t *testing.T) {
}
})
}
func TestStreamEventWithPermissionDenials(t *testing.T) {
// Simulate Claude API response with permission denials
jsonData := `{
"type": "result",
"subtype": "completion",
"session_id": "test-session",
"total_cost_usd": 0.05,
"is_error": false,
"result": "Successfully created PR #430",
"permission_denials": [{
"tool_name": "Bash",
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
"tool_input": {"command": "git fetch origin main"}
}]
}`
var event claudecode.StreamEvent
err := json.Unmarshal([]byte(jsonData), &event)
if err != nil {
t.Fatalf("Failed to unmarshal event with permission denials: %v", err)
}
// Verify permission denials were parsed
if event.PermissionDenials == nil || len(event.PermissionDenials.Denials) != 1 {
t.Error("Permission denials not properly parsed")
}
// Verify session is not marked as error despite denials
if event.IsError {
t.Error("Event marked as error when it should be successful with denials")
}
// Verify denial details
if event.PermissionDenials.Denials[0].ToolName != "Bash" {
t.Errorf("Expected tool name 'Bash', got %s", event.PermissionDenials.Denials[0].ToolName)
}
}

View File

@@ -50,6 +50,9 @@ type SessionConfig struct {
// Required
Query string
// Optional title for the session
Title string
// Session management
SessionID string // If set, resumes this session
@@ -87,15 +90,72 @@ type StreamEvent struct {
APIKeySource string `json:"apiKeySource,omitempty"`
// Result event fields (when type="result")
CostUSD float64 `json:"total_cost_usd,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
DurationAPI int `json:"duration_api_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Result string `json:"result,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials []string `json:"permission_denials,omitempty"`
CostUSD float64 `json:"total_cost_usd,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
DurationAPI int `json:"duration_api_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Result string `json:"result,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
}
// PermissionDenial represents a single permission denial from Claude
type PermissionDenial struct {
ToolName string `json:"tool_name"`
ToolUseID string `json:"tool_use_id"`
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
}
// PermissionDenials handles flexible permission denial formats
type PermissionDenials struct {
Denials []PermissionDenial
}
// UnmarshalJSON implements custom unmarshaling to handle both object and string array formats
func (p *PermissionDenials) UnmarshalJSON(data []byte) error {
// Handle null
if string(data) == "null" {
p.Denials = nil
return nil
}
// Try as array of objects first (current API format)
var denials []PermissionDenial
if err := json.Unmarshal(data, &denials); err == nil {
p.Denials = denials
return nil
}
// Fall back to array of strings (legacy/simple format)
var legacyStrings []string
if err := json.Unmarshal(data, &legacyStrings); err == nil {
p.Denials = make([]PermissionDenial, len(legacyStrings))
for i, s := range legacyStrings {
p.Denials[i] = PermissionDenial{ToolName: s}
}
return nil
}
return fmt.Errorf("permission_denials is neither object array nor string array format")
}
// MarshalJSON implements custom marshaling
func (p PermissionDenials) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Denials)
}
// ToStrings converts denials to string array for backward compatibility
func (p PermissionDenials) ToStrings() []string {
if p.Denials == nil {
return nil
}
result := make([]string, len(p.Denials))
for i, d := range p.Denials {
result[i] = d.ToolName
}
return result
}
// MCPStatus represents the status of an MCP server
@@ -182,18 +242,18 @@ type Usage struct {
// Result represents the final result of a Claude session
type Result struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
CostUSD float64 `json:"total_cost_usd"`
IsError bool `json:"is_error"`
DurationMS int `json:"duration_ms"`
DurationAPI int `json:"duration_api_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials []string `json:"permission_denials,omitempty"`
Type string `json:"type"`
Subtype string `json:"subtype"`
CostUSD float64 `json:"total_cost_usd"`
IsError bool `json:"is_error"`
DurationMS int `json:"duration_ms"`
DurationAPI int `json:"duration_api_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id"`
Usage *Usage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
}
// Session represents an active Claude session

View File

@@ -207,3 +207,117 @@ func TestContentStructWithContentField(t *testing.T) {
})
}
}
func TestPermissionDenialsUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
expectDenials []PermissionDenial
expectError bool
}{
{
name: "object array format",
input: `[{
"tool_name": "Bash",
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
"tool_input": {"command": "git fetch origin main"}
}]`,
expectDenials: []PermissionDenial{{
ToolName: "Bash",
ToolUseID: "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
ToolInput: map[string]interface{}{"command": "git fetch origin main"},
}},
expectError: false,
},
{
name: "multiple denials",
input: `[
{"tool_name": "Bash", "tool_use_id": "tool_1", "tool_input": {"command": "rm -rf /"}},
{"tool_name": "Write", "tool_use_id": "tool_2", "tool_input": {"path": "/etc/passwd"}}
]`,
expectDenials: []PermissionDenial{
{ToolName: "Bash", ToolUseID: "tool_1", ToolInput: map[string]interface{}{"command": "rm -rf /"}},
{ToolName: "Write", ToolUseID: "tool_2", ToolInput: map[string]interface{}{"path": "/etc/passwd"}},
},
expectError: false,
},
{
name: "legacy string array format",
input: `["Bash", "Write", "Delete"]`,
expectDenials: []PermissionDenial{{ToolName: "Bash"}, {ToolName: "Write"}, {ToolName: "Delete"}},
expectError: false,
},
{
name: "empty array",
input: `[]`,
expectDenials: []PermissionDenial{},
expectError: false,
},
{
name: "null value",
input: `null`,
expectDenials: nil,
expectError: false,
},
{
name: "invalid format - plain object",
input: `{"tool_name": "Bash"}`,
expectError: true,
},
{
name: "invalid format - number",
input: `123`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var p PermissionDenials
err := json.Unmarshal([]byte(tt.input), &p)
if (err != nil) != tt.expectError {
t.Errorf("PermissionDenials.UnmarshalJSON() error = %v, expectError %v", err, tt.expectError)
return
}
if !tt.expectError {
if len(p.Denials) != len(tt.expectDenials) {
t.Errorf("PermissionDenials.UnmarshalJSON() got %d denials, want %d", len(p.Denials), len(tt.expectDenials))
return
}
for i, denial := range p.Denials {
if denial.ToolName != tt.expectDenials[i].ToolName {
t.Errorf("Denial[%d].ToolName = %v, want %v", i, denial.ToolName, tt.expectDenials[i].ToolName)
}
if denial.ToolUseID != tt.expectDenials[i].ToolUseID {
t.Errorf("Denial[%d].ToolUseID = %v, want %v", i, denial.ToolUseID, tt.expectDenials[i].ToolUseID)
}
}
}
})
}
}
func TestPermissionDenialsToStrings(t *testing.T) {
p := PermissionDenials{
Denials: []PermissionDenial{
{ToolName: "Bash"},
{ToolName: "Write"},
},
}
strings := p.ToStrings()
expected := []string{"Bash", "Write"}
if len(strings) != len(expected) {
t.Errorf("ToStrings() returned %d items, want %d", len(strings), len(expected))
}
for i, s := range strings {
if s != expected[i] {
t.Errorf("ToStrings()[%d] = %v, want %v", i, s, expected[i])
}
}
}

View File

@@ -62,6 +62,9 @@ func (h *SessionHandlers) CreateSession(ctx context.Context, req api.CreateSessi
}
// Handle optional fields
if req.Body.Title != nil {
config.Title = *req.Body.Title
}
if req.Body.PermissionPromptTool != nil {
config.PermissionPromptTool = *req.Body.PermissionPromptTool
}

View File

@@ -696,6 +696,10 @@ components:
type: string
description: Initial query for Claude
example: "Help me write a Python script to process CSV files"
title:
type: string
description: Optional title for the session
example: "CSV Processing Script"
model:
type: string
enum: [opus, sonnet]

View File

@@ -310,6 +310,9 @@ type CreateSessionRequest struct {
// SystemPrompt Override system prompt
SystemPrompt *string `json:"system_prompt,omitempty"`
// Title Optional title for the session
Title *string `json:"title,omitempty"`
// Verbose Enable verbose output
Verbose *bool `json:"verbose,omitempty"`
@@ -2182,96 +2185,94 @@ func (sh *strictHandler) GetSessionSnapshots(ctx *gin.Context, id SessionId) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"fwEAAP//OkGjnhNtAAA=",
"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",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -117,14 +117,14 @@ func TestIntegrationContinueSession(t *testing.T) {
return nil, fmt.Errorf("no result in response")
}
t.Run("ContinueSession_RequiresCompletedOrRunningParent", func(t *testing.T) {
// Create a parent session that's failed (should be rejected)
parentSessionID := "parent-failed"
t.Run("ContinueSession_AllowsFailedSessionWithClaudeID", func(t *testing.T) {
// Create a parent session that's failed with valid claude_session_id (should be allowed)
parentSessionID := "parent-failed-valid"
parentSession := &store.Session{
ID: parentSessionID,
RunID: "run-parent",
ClaudeSessionID: "claude-parent",
Status: store.SessionStatusFailed, // Neither completed nor running
Status: store.SessionStatusFailed,
Query: "original query",
WorkingDir: "/tmp",
CreatedAt: time.Now(),
@@ -136,7 +136,44 @@ func TestIntegrationContinueSession(t *testing.T) {
t.Fatalf("Failed to create parent session: %v", err)
}
// Try to continue the failed session
// Try to continue the failed session - should now succeed (or fail with Claude launch error)
req := rpc.ContinueSessionRequest{
SessionID: parentSessionID,
Query: "continue this",
}
_, err := sendRPC(t, "continueSession", req)
if err != nil {
// Expected - Claude binary might not exist in test environment
expectedErr1 := "failed to continue session: failed to launch resumed Claude session: failed to start claude: exec: \"claude\": executable file not found in $PATH"
expectedErr2 := "failed to continue session: failed to launch resumed Claude session: failed to start claude: chdir"
if err.Error() != expectedErr1 && !strings.Contains(err.Error(), expectedErr2) {
t.Errorf("Unexpected error: %v", err)
}
// Even if Claude fails to launch, the session should have been created
}
})
t.Run("ContinueSession_RejectsFailedSessionWithoutClaudeID", func(t *testing.T) {
// Create a parent session that's failed WITHOUT claude_session_id (should still be rejected)
parentSessionID := "parent-failed-no-claude"
parentSession := &store.Session{
ID: parentSessionID,
RunID: "run-parent-no-claude",
ClaudeSessionID: "", // Missing claude_session_id
Status: store.SessionStatusFailed,
Query: "original query",
WorkingDir: "/tmp",
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
}
// Insert parent session directly into database
if err := d.store.CreateSession(ctx, parentSession); err != nil {
t.Fatalf("Failed to create parent session: %v", err)
}
// Try to continue the failed session without claude_session_id
req := rpc.ContinueSessionRequest{
SessionID: parentSessionID,
Query: "continue this",
@@ -144,9 +181,43 @@ func TestIntegrationContinueSession(t *testing.T) {
_, err := sendRPC(t, "continueSession", req)
if err == nil {
t.Error("Expected error when continuing failed session")
t.Error("Expected error when continuing failed session without claude_session_id")
}
if err.Error() != "cannot continue session with status failed (must be completed, interrupted, or running)" {
if err.Error() != "parent session missing claude_session_id (cannot resume)" {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("ContinueSession_RejectsInvalidStatus", func(t *testing.T) {
// Create a parent session with an invalid status (e.g., starting)
parentSessionID := "parent-invalid-status"
parentSession := &store.Session{
ID: parentSessionID,
RunID: "run-invalid",
ClaudeSessionID: "claude-invalid",
Status: store.SessionStatusStarting, // Invalid status for continuation
Query: "original query",
WorkingDir: "/tmp",
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
}
// Insert parent session
if err := d.store.CreateSession(ctx, parentSession); err != nil {
t.Fatalf("Failed to create parent session: %v", err)
}
// Try to continue with invalid status
req := rpc.ContinueSessionRequest{
SessionID: parentSessionID,
Query: "continue this",
}
_, err := sendRPC(t, "continueSession", req)
if err == nil {
t.Error("Expected error when continuing session with invalid status")
}
if err.Error() != "cannot continue session with status starting (must be completed, interrupted, running, or failed)" {
t.Errorf("Unexpected error: %v", err)
}
})

View File

@@ -231,14 +231,13 @@ func TestIntegrationResumeDuringRunning(t *testing.T) {
{
name: "failed session",
status: store.SessionStatusFailed,
shouldSucceed: false,
expectedError: "cannot continue session with status failed (must be completed, interrupted, or running)",
shouldSucceed: true, // Now allowed
},
{
name: "starting session",
status: store.SessionStatusStarting,
shouldSucceed: false,
expectedError: "cannot continue session with status starting (must be completed, interrupted, or running)",
expectedError: "cannot continue session with status starting (must be completed, interrupted, running, or failed)",
},
}

View File

@@ -42,6 +42,7 @@ func (h *SessionHandlers) SetEventBus(eventBus bus.EventBus) {
// LaunchSessionRequest is the request for launching a new session
type LaunchSessionRequest struct {
Query string `json:"query"`
Title string `json:"title,omitempty"`
Model string `json:"model,omitempty"`
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
@@ -79,6 +80,7 @@ func (h *SessionHandlers) HandleLaunchSession(ctx context.Context, params json.R
config := session.LaunchSessionConfig{
SessionConfig: claudecode.SessionConfig{
Query: req.Query,
Title: req.Title,
MCPConfig: req.MCPConfig,
PermissionPromptTool: req.PermissionPromptTool,
WorkingDir: req.WorkingDir,

View File

@@ -33,6 +33,12 @@ export interface CreateSessionRequest {
* @memberof CreateSessionRequest
*/
query: string;
/**
* Optional title for the session
* @type {string}
* @memberof CreateSessionRequest
*/
title?: string;
/**
* Model to use for the session
* @type {string}
@@ -143,6 +149,7 @@ export function CreateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
return {
'query': json['query'],
'title': json['title'] == null ? undefined : json['title'],
'model': json['model'] == null ? undefined : json['model'],
'mcpConfig': json['mcp_config'] == null ? undefined : MCPConfigFromJSON(json['mcp_config']),
'permissionPromptTool': json['permission_prompt_tool'] == null ? undefined : json['permission_prompt_tool'],
@@ -171,6 +178,7 @@ export function CreateSessionRequestToJSONTyped(value?: CreateSessionRequest | n
return {
'query': value['query'],
'title': value['title'],
'model': value['model'],
'mcp_config': MCPConfigToJSON(value['mcpConfig']),
'permission_prompt_tool': value['permissionPromptTool'],

View File

@@ -1113,11 +1113,12 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
return nil, fmt.Errorf("failed to get parent session: %w", err)
}
// Validate parent session status - allow completed, interrupted, or running sessions
// Validate parent session status - allow completed, interrupted, running, or failed sessions
if parentSession.Status != store.SessionStatusCompleted &&
parentSession.Status != store.SessionStatusInterrupted &&
parentSession.Status != store.SessionStatusRunning {
return nil, fmt.Errorf("cannot continue session with status %s (must be completed, interrupted, or running)", parentSession.Status)
parentSession.Status != store.SessionStatusRunning &&
parentSession.Status != store.SessionStatusFailed {
return nil, fmt.Errorf("cannot continue session with status %s (must be completed, interrupted, running, or failed)", parentSession.Status)
}
// Validate parent session has claude_session_id (needed for resume)
@@ -1333,8 +1334,21 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
}
// Launch resumed Claude session
slog.Info("attempting to resume Claude session",
"session_id", sessionID,
"parent_session_id", req.ParentSessionID,
"parent_status", parentSession.Status,
"claude_session_id", parentSession.ClaudeSessionID,
"query", req.Query)
claudeSession, err := m.client.Launch(config)
if err != nil {
slog.Error("failed to resume Claude session from failed parent",
"session_id", sessionID,
"parent_session_id", req.ParentSessionID,
"parent_status", parentSession.Status,
"claude_session_id", parentSession.ClaudeSessionID,
"error", err)
m.updateSessionStatus(ctx, sessionID, StatusFailed, err.Error())
return nil, fmt.Errorf("failed to launch resumed Claude session: %w", err)
}

View File

@@ -151,22 +151,26 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
testCases := []struct {
name string
parentStatus string
hasWorkingDir bool
expectedError string
}{
{
name: "failed session",
name: "failed session without working_dir",
parentStatus: store.SessionStatusFailed,
expectedError: "cannot continue session with status failed (must be completed, interrupted, or running)",
hasWorkingDir: false,
expectedError: "parent session missing working_dir (cannot resume session without working directory)",
},
{
name: "starting session",
parentStatus: store.SessionStatusStarting,
expectedError: "cannot continue session with status starting (must be completed, interrupted, or running)",
hasWorkingDir: true,
expectedError: "cannot continue session with status starting (must be completed, interrupted, running, or failed)",
},
{
name: "waiting input session",
parentStatus: store.SessionStatusWaitingInput,
expectedError: "cannot continue session with status waiting_input (must be completed, interrupted, or running)",
hasWorkingDir: true,
expectedError: "cannot continue session with status waiting_input (must be completed, interrupted, running, or failed)",
},
}
@@ -180,6 +184,9 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
Query: "original query",
CreatedAt: time.Now(),
}
if tc.hasWorkingDir {
parentSession.WorkingDir = "/tmp"
}
mockStore.EXPECT().GetSession(gomock.Any(), "parent-1").Return(parentSession, nil)
req := ContinueSessionConfig{
@@ -197,6 +204,64 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
}
}
func TestContinueSession_AllowsFailedSessionWithValidRequirements(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Create a context that gets cancelled when test finishes
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
// Give goroutines a moment to clean up
time.Sleep(50 * time.Millisecond)
}()
mockStore := store.NewMockConversationStore(ctrl)
manager, _ := NewManager(nil, mockStore, "")
// Create a failed parent session WITH valid claude_session_id and working_dir
failedParentSession := &store.Session{
ID: "parent-failed-valid",
RunID: "run-failed",
ClaudeSessionID: "claude-failed-valid", // Has valid session ID
Status: store.SessionStatusFailed,
Query: "original query that failed",
WorkingDir: "/tmp", // Has valid working directory
CreatedAt: time.Now(),
}
mockStore.EXPECT().GetSession(gomock.Any(), "parent-failed-valid").Return(failedParentSession, nil)
mockStore.EXPECT().GetMCPServers(gomock.Any(), "parent-failed-valid").Return([]store.MCPServer{}, nil)
mockStore.EXPECT().CreateSession(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx interface{}, session *store.Session) error {
// Validate the created session
if session.ParentSessionID != "parent-failed-valid" {
t.Errorf("Expected parent_session_id to be 'parent-failed-valid', got '%s'", session.ParentSessionID)
}
if session.Query != "retry after failure" {
t.Errorf("Expected query 'retry after failure', got '%s'", session.Query)
}
return nil
})
mockStore.EXPECT().StoreMCPServers(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockStore.EXPECT().UpdateSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
req := ContinueSessionConfig{
ParentSessionID: "parent-failed-valid",
Query: "retry after failure",
}
// This should now succeed (or fail with Claude launch error, not validation error)
_, err := manager.ContinueSession(ctx, req)
if err != nil {
// Expected - Claude binary might not exist
if !containsError(err, "failed to launch resumed Claude session") {
t.Errorf("Unexpected error: %v", err)
}
}
// The important thing is we didn't get a validation error about the failed status
}
func TestContinueSession_ValidatesClaudeSessionID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

View File

@@ -257,6 +257,7 @@ func NewSessionFromConfig(id, runID string, config claudecode.SessionConfig) *Se
ID: id,
RunID: runID,
Query: config.Query,
Title: config.Title,
Model: string(config.Model),
WorkingDir: config.WorkingDir,
MaxTurns: config.MaxTurns,

View File

@@ -5,6 +5,7 @@ import { join } from 'path'
interface LaunchOptions {
query?: string
title?: string
model?: string
workingDir?: string
maxTurns?: number
@@ -28,6 +29,7 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
console.log('Launching Claude Code session...')
console.log('Query:', query)
if (options.title) console.log('Title:', options.title)
if (options.model) console.log('Model:', options.model)
console.log('Working directory:', options.workingDir || process.cwd())
console.log('Approvals enabled:', options.approvals !== false)
@@ -62,6 +64,7 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
// Launch the session
const result = await client.launchSession({
query: query,
title: options.title,
model: options.model,
working_dir: options.workingDir || process.cwd(),
max_turns: options.maxTurns,

View File

@@ -86,6 +86,7 @@ export interface Approval {
interface LaunchSessionRequest {
query: string
title?: string
model?: string
mcp_config?: unknown
permission_prompt_tool?: string
@@ -97,6 +98,8 @@ interface LaunchSessionRequest {
disallowed_tools?: string[]
custom_instructions?: string
verbose?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout?: number
}
interface LaunchSessionResponse {

View File

@@ -100,6 +100,7 @@ program
.command('launch <query>')
.description('Launch a new Claude Code session via the daemon')
.option('-m, --model <model>', 'Model to use (opus or sonnet)', 'sonnet')
.option('-t, --title <title>', 'Optional session title')
.option('-w, --working-dir <path>', 'Working directory for the session')
.option('--max-turns <number>', 'Maximum number of turns', parseInt)
.option('--no-approvals', 'Disable HumanLayer approvals for high-stakes operations')

View File

@@ -4,11 +4,12 @@ import { SearchInput } from './FuzzySearchInput'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { useRecentPaths } from '@/hooks/useRecentPaths'
import { Textarea } from './ui/textarea'
import { Input } from './ui/input'
import { Label } from './ui/label'
import { hasContent, isEmptyOrWhitespace } from '@/utils/validation'
interface SessionConfig {
query: string
title?: string
workingDir: string
model?: string
maxTurns?: number
@@ -30,7 +31,7 @@ export default function CommandInput({
onSubmit,
placeholder = 'Enter your command...',
isLoading = false,
config = { query: '', workingDir: '' },
config = { workingDir: '' },
onConfigChange,
}: CommandInputProps) {
const promptRef = useRef<HTMLTextAreaElement>(null)
@@ -87,6 +88,19 @@ export default function CommandInput({
/>
</div>
{/* Title Field (optional) */}
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
type="text"
value={config.title || ''}
onChange={e => onConfigChange?.({ ...config, title: e.target.value })}
placeholder="Optional session title"
disabled={isLoading}
/>
</div>
{/* Main query input */}
<div className="relative space-y-2">
<Label htmlFor="prompt">Prompt</Label>

View File

@@ -38,7 +38,7 @@ export const DangerousSkipPermissionsMonitor = () => {
// Show notification using NotificationService
await notificationService.notify({
type: 'settings_changed',
title: 'Dangerous skip permissions expired',
title: 'Bypass permissions expired',
body: `Session: ${session.title || session.summary || 'Untitled'}`,
metadata: {
sessionId: session.id,

View File

@@ -79,7 +79,8 @@ export function Layout() {
const session = sessionResponse.session
// Always update the session in the sessions list
useStore.getState().updateSessionOptimistic(session_id, session)
// Use updateSession (not updateSessionOptimistic) since this is data FROM the server
useStore.getState().updateSession(session_id, session)
// Update active session detail if this is the currently viewed session
const activeSessionId = useStore.getState().activeSessionDetail?.session?.id

View File

@@ -40,7 +40,7 @@ export const SessionModeIndicator: FC<SessionModeIndicatorProps> = ({
})
// Show notification
toast.info('Dangerous skip permissions expired', {
toast.info('Bypass permissions expired', {
description: 'Manual approval required for all tools',
duration: 5000,
})

View File

@@ -60,6 +60,26 @@ const DangerouslySkipPermissionsDialogContent: FC<{
},
)
// Add meta+enter to submit
useHotkeys(
'meta+enter, ctrl+enter',
ev => {
ev.preventDefault()
ev.stopPropagation()
// Only submit if the button would be enabled
if (!useTimeout || (timeoutMinutes !== '' && timeoutMinutes !== 0)) {
handleConfirm()
}
},
{
enabled: isOpen,
scopes: DangerouslySkipPermissionsHotkeysScope,
preventDefault: true,
enableOnFormTags: true,
},
)
// Reset to default when component mounts (dialog opens)
React.useEffect(() => {
setTimeoutMinutes(15)
@@ -155,6 +175,11 @@ const DangerouslySkipPermissionsDialogContent: FC<{
className="border-[var(--terminal-error)] text-[var(--terminal-error)] hover:bg-[var(--terminal-error)] hover:text-background disabled:opacity-50 disabled:cursor-not-allowed"
>
Bypass Permissions
{!useTimeout || (timeoutMinutes !== '' && timeoutMinutes !== 0) ? (
<kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">
{navigator.platform.toLowerCase().includes('mac') ? '⌘' : 'Ctrl'}+
</kbd>
) : null}
</Button>
</DialogFooter>
</>

View File

@@ -577,7 +577,12 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
return
}
if (dangerouslySkipPermissions) {
// Get the current value from the store directly to avoid stale closure
const currentSessionFromStore = useStore.getState().sessions.find(s => s.id === session.id)
const currentDangerouslySkipPermissions =
currentSessionFromStore?.dangerouslySkipPermissions ?? false
if (currentDangerouslySkipPermissions) {
// Disable dangerous skip permissions
try {
await updateSessionOptimistic(session.id, {
@@ -597,7 +602,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
preventDefault: true,
scopes: SessionDetailHotkeysScope,
},
[session.id, dangerouslySkipPermissions],
[session.id], // Remove dangerouslySkipPermissions from deps since we get it fresh each time
)
// Handle dialog confirmation
@@ -765,7 +770,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
useHotkeys(
'enter',
() => {
if (responseInputRef.current && session.status !== SessionStatus.Failed) {
if (responseInputRef.current) {
responseInputRef.current.focus()
}
},
@@ -935,7 +940,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
onSelectEvent={handleForkSelect}
isOpen={forkViewOpen}
onOpenChange={setForkViewOpen}
sessionStatus={session.status}
/>
</div>
)}
@@ -1026,7 +1030,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
onSelectEvent={handleForkSelect}
isOpen={forkViewOpen}
onOpenChange={setForkViewOpen}
sessionStatus={session.status}
/>
</div>
)}
@@ -1127,7 +1130,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
handleContinueSession={actions.handleContinueSession}
handleResponseInputKeyDown={actions.handleResponseInputKeyDown}
isForkMode={actions.isForkMode}
onOpenForkView={() => setForkViewOpen(true)}
/>
{/* Session mode indicator - shows either dangerous skip permissions or auto-accept */}
<SessionModeIndicator

View File

@@ -1,6 +1,28 @@
import React, { Fragment } from 'react'
import { logger } from '@/lib/logging'
// --- Debug utilities ---
// Preserved for debugging - uncomment the invocation below to enable
// @ts-expect-error - Intentionally unused, preserved for debugging
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function debugLogSnapshotFailure(
error: unknown,
edits: { oldValue: string; newValue: string }[],
fileContents?: string,
) {
logger.warn('Snapshot-based diff rendering failed, falling back to simple diff:', {
error: error instanceof Error ? error.message : String(error),
editsCount: edits.length,
fileContentLength: fileContents?.length,
firstEditPreview: edits[0]
? {
oldValue: edits[0].oldValue.substring(0, 50) + '...',
newValue: edits[0].newValue.substring(0, 50) + '...',
}
: null,
})
}
// --- Minimal diff utilities (no third-party libraries) ---
function computeLineDiff(oldStr: string, newStr: string) {
// Returns an array of { type: 'equal'|'add'|'remove'|'replace', oldLine?: string, newLine?: string, oldIndex?: number, newIndex?: number }
@@ -600,19 +622,8 @@ export const CustomDiffViewer = ({
</div>
</div>
)
} catch (error) {
// Log detailed context for debugging
logger.warn('Snapshot-based diff rendering failed, falling back to simple diff:', {
error: error instanceof Error ? error.message : String(error),
editsCount: edits.length,
fileContentLength: fileContents?.length,
firstEditPreview: edits[0]
? {
oldValue: edits[0].oldValue.substring(0, 50) + '...',
newValue: edits[0].newValue.substring(0, 50) + '...',
}
: null,
})
} catch {
// debugLogSnapshotFailure(error, edits, fileContents) // uncomment to enable debug logging
// Recursively call self with undefined fileContents to trigger non-snapshot mode
return <CustomDiffViewer fileContents={undefined} edits={edits} splitView={splitView} />

View File

@@ -10,7 +10,7 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ConversationEvent, SessionStatus } from '@/lib/daemon/types'
import { ConversationEvent } from '@/lib/daemon/types'
import { cn } from '@/lib/utils'
import { useStealHotkeyScope } from '@/hooks/useStealHotkeyScope'
@@ -22,7 +22,6 @@ interface ForkViewModalProps {
onSelectEvent: (index: number | null) => void
isOpen: boolean
onOpenChange: (open: boolean) => void
sessionStatus?: SessionStatus
}
function ForkViewModalContent({
@@ -30,7 +29,6 @@ function ForkViewModalContent({
selectedEventIndex,
onSelectEvent,
onClose,
sessionStatus,
}: Omit<ForkViewModalProps, 'isOpen' | 'onOpenChange'> & { onClose: () => void }) {
// Steal hotkey scope when this component mounts
useStealHotkeyScope(ForkViewModalHotkeysScope)
@@ -57,8 +55,8 @@ function ForkViewModalContent({
.filter(({ event }) => event.eventType === 'message' && event.role === 'user')
.slice(1) // Exclude first message since it can't be forked
// Add current option as a special index (-1) only if session is not failed
const showCurrentOption = sessionStatus !== SessionStatus.Failed
// Add current option as a special index (-1) for all sessions
const showCurrentOption = true
const allOptions = showCurrentOption
? [...userMessageIndices, { event: null, index: -1 }]
: userMessageIndices
@@ -193,7 +191,7 @@ function ForkViewModalContent({
)
})}
{/* Current option - only show if session is not failed */}
{/* Current option */}
{showCurrentOption && (
<div className="border-t mt-2 pt-2">
<div
@@ -239,7 +237,6 @@ export function ForkViewModal({
onSelectEvent,
isOpen,
onOpenChange,
sessionStatus,
}: ForkViewModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -272,7 +269,6 @@ export function ForkViewModal({
selectedEventIndex={selectedEventIndex}
onSelectEvent={onSelectEvent}
onClose={() => onOpenChange(false)}
sessionStatus={sessionStatus}
/>
)}
</DialogContent>

View File

@@ -3,12 +3,10 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Session, SessionStatus } from '@/lib/daemon/types'
import {
getSessionStatusText,
getInputPlaceholder,
getHelpText,
getForkInputPlaceholder,
} from '@/components/internal/SessionDetail/utils/sessionStatus'
import { GitBranch } from 'lucide-react'
import { ResponseInputLocalStorageKey } from '@/components/internal/SessionDetail/hooks/useSessionActions'
interface ResponseInputProps {
@@ -19,7 +17,6 @@ interface ResponseInputProps {
handleContinueSession: () => void
handleResponseInputKeyDown: (e: React.KeyboardEvent) => void
isForkMode?: boolean
onOpenForkView?: () => void
}
export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>(
@@ -32,7 +29,6 @@ export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>
handleContinueSession,
handleResponseInputKeyDown,
isForkMode,
onOpenForkView,
},
ref,
) => {
@@ -64,22 +60,7 @@ export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>
// Regular help text
return getHelpText(session.status)
}
// Only show the simple status text if session is failed AND not in fork mode
if (session.status === SessionStatus.Failed && !isForkMode) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-sm text-muted-foreground">{getSessionStatusText(session.status)}</span>
{onOpenForkView && (
<Button variant="ghost" size="sm" onClick={onOpenForkView} className="h-8 gap-2">
<GitBranch className="h-4 w-4" />
Fork from previous
</Button>
)}
</div>
)
}
// Otherwise always show the input
// Always show the input for all session states
return (
<div className="space-y-2">
{isForkMode && <span className="text-sm font-medium">Fork from this message:</span>}

View File

@@ -84,6 +84,23 @@ export function ToolResultModal({
},
)
// Handle 'i' to close (toggle behavior)
useHotkeys(
'i',
ev => {
ev.preventDefault()
ev.stopPropagation()
if (toolResult || toolCall) {
onClose()
}
},
{
enabled: !!(toolResult || toolCall),
scopes: ToolResultModalHotkeysScope,
preventDefault: true,
},
)
const isOpen = !!(toolResult || toolCall)
useStealHotkeyScope(ToolResultModalHotkeysScope, isOpen)
@@ -167,7 +184,7 @@ export function ToolResultModal({
<kbd>j/k</kbd> or <kbd>/</kbd> to scroll
</span>
<span className="text-xs text-muted-foreground">
<kbd>ESC</kbd> to close
<kbd>i</kbd> or <kbd>ESC</kbd> to close
</span>
</div>
</DialogContent>

View File

@@ -11,19 +11,19 @@ export const Kbd = ({
export const getSessionStatusText = (status: string): string => {
if (status === 'completed') return 'Continue this conversation with a new message'
if (status === 'interrupted') return 'Session was interrupted - continue with a new message'
if (status === 'failed') return 'Session failed - continue with a new message to retry'
if (status === 'running' || status === 'starting')
return 'Claude is working - you can interrupt with a new message'
return 'Session must be completed to continue'
}
export const getInputPlaceholder = (status: string): string => {
if (status === 'failed') return 'Session failed - cannot continue...'
if (status === 'failed') return 'Enter your message to retry from where it failed...'
if (status === 'running' || status === 'starting') return 'Enter message to interrupt...'
return 'Enter your message to continue the conversation...'
}
export const getHelpText = (status: string): React.ReactNode => {
if (status === 'failed') return 'Session failed - cannot continue'
const isMac = navigator.platform.includes('Mac')
const sendKey = isMac ? '⌘+Enter' : 'Ctrl+Enter'
const skipKey = isMac ? '⌥+Y' : 'Alt+Y'

View File

@@ -9,7 +9,7 @@ import { homeDir } from '@tauri-apps/api/path'
import { logger } from '@/lib/logging'
interface SessionConfig {
query: string
title?: string
workingDir: string
model?: string
maxTurns?: number
@@ -41,6 +41,7 @@ interface LauncherState {
}
const LAST_WORKING_DIR_KEY = 'humanlayer-last-working-dir'
const SESSION_LAUNCHER_QUERY_KEY = 'session-launcher-query'
// Helper function to get default working directory
const getDefaultWorkingDir = (): string => {
@@ -48,12 +49,17 @@ const getDefaultWorkingDir = (): string => {
return stored || '~/' // Default to home directory on first launch
}
// Helper function to get saved query
const getSavedQuery = (): string => {
return localStorage.getItem(SESSION_LAUNCHER_QUERY_KEY) || ''
}
export const useSessionLauncher = create<LauncherState>((set, get) => ({
isOpen: false,
mode: 'command',
view: 'menu',
query: '',
config: { query: '', workingDir: getDefaultWorkingDir() },
query: getSavedQuery(),
config: { workingDir: getDefaultWorkingDir() },
isLaunching: false,
gPrefixMode: false,
selectedMenuIndex: 0,
@@ -68,23 +74,26 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
}),
close: () => {
const savedQuery = getSavedQuery()
set({
isOpen: false,
view: 'menu',
query: '',
config: { query: '', workingDir: getDefaultWorkingDir() },
query: savedQuery,
config: { workingDir: getDefaultWorkingDir() },
selectedMenuIndex: 0,
error: undefined,
gPrefixMode: false,
})
},
setQuery: query =>
set(state => ({
setQuery: query => {
// Save to localStorage on every change
localStorage.setItem(SESSION_LAUNCHER_QUERY_KEY, query)
return set({
query,
config: { ...state.config, query },
error: undefined,
})),
})
},
setConfig: config => set({ config, error: undefined }),
@@ -141,6 +150,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
const request: LaunchSessionRequest = {
query: query.trim(),
title: config.title || undefined,
working_dir: config.workingDir || undefined,
model: config.model || undefined,
max_turns: config.maxTurns || undefined,
@@ -155,6 +165,9 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
localStorage.setItem(LAST_WORKING_DIR_KEY, config.workingDir)
}
// Clear the saved query after successful launch
localStorage.removeItem(SESSION_LAUNCHER_QUERY_KEY)
// Navigate to new session (will be handled by parent component)
window.location.hash = `#/sessions/${response.sessionId}`
@@ -174,11 +187,12 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
},
createNewSession: () => {
const savedQuery = getSavedQuery()
// Switch to input mode for session creation
set({
view: 'input',
query: '',
config: { query: '', workingDir: getDefaultWorkingDir() },
query: savedQuery,
config: { workingDir: getDefaultWorkingDir() },
error: undefined,
})
},
@@ -189,18 +203,20 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
get().close()
},
reset: () =>
set({
reset: () => {
const savedQuery = getSavedQuery()
return set({
isOpen: false,
mode: 'command',
view: 'menu',
query: '',
config: { query: '', workingDir: getDefaultWorkingDir() },
query: savedQuery,
config: { workingDir: getDefaultWorkingDir() },
selectedMenuIndex: 0,
isLaunching: false,
error: undefined,
gPrefixMode: false,
}),
})
},
}))
// Helper hook for global hotkey management

View File

@@ -136,6 +136,7 @@ export class HTTPDaemonClient implements IDaemonClient {
const response = await this.client!.createSession({
query: params.query,
title: 'title' in params ? params.title : undefined,
workingDir:
'workingDir' in params ? params.workingDir : (params as LaunchSessionRequest).working_dir,
model: model,

View File

@@ -151,6 +151,7 @@ export enum ViewMode {
// Legacy request/response types (for gradual migration)
export interface LaunchSessionRequest {
query: string
title?: string
model?: string
mcp_config?: any
permission_prompt_tool?: string