Merge pull request #405 from samdickson22/sam/eng-1696-hotkey-for-yolo-mode

feat: add yolo mode hotkey and improve session status indicators
This commit is contained in:
Dex
2025-08-07 17:30:25 -07:00
committed by GitHub
44 changed files with 2572 additions and 395 deletions

View File

@@ -12,7 +12,9 @@ import (
"github.com/humanlayer/humanlayer/hld/internal/version"
"github.com/humanlayer/humanlayer/hld/session"
"github.com/humanlayer/humanlayer/hld/store"
"log/slog"
"sort"
"time"
)
type SessionHandlers struct {
@@ -35,10 +37,13 @@ func NewSessionHandlers(manager session.SessionManager, store store.Conversation
// CreateSession implements POST /sessions
func (h *SessionHandlers) CreateSession(ctx context.Context, req api.CreateSessionRequestObject) (api.CreateSessionResponseObject, error) {
config := claudecode.SessionConfig{
Query: req.Body.Query,
MCPConfig: h.mapper.MCPConfigFromAPI(req.Body.McpConfig),
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
// Build launch config with embedded Claude config
config := session.LaunchSessionConfig{
SessionConfig: claudecode.SessionConfig{
Query: req.Body.Query,
MCPConfig: h.mapper.MCPConfigFromAPI(req.Body.McpConfig),
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
},
}
// Handle optional fields
@@ -172,22 +177,24 @@ func (h *SessionHandlers) ListSessions(ctx context.Context, req api.ListSessions
for i, info := range filtered {
// Convert Info to store.Session for mapper
storeSession := store.Session{
ID: info.ID,
RunID: info.RunID,
ClaudeSessionID: info.ClaudeSessionID,
ParentSessionID: info.ParentSessionID,
Status: string(info.Status),
Query: info.Query,
Summary: info.Summary,
Title: info.Title,
Model: info.Model,
WorkingDir: info.WorkingDir,
CreatedAt: info.StartTime,
LastActivityAt: info.LastActivityAt,
CompletedAt: info.EndTime,
ErrorMessage: info.Error,
AutoAcceptEdits: info.AutoAcceptEdits,
Archived: info.Archived,
ID: info.ID,
RunID: info.RunID,
ClaudeSessionID: info.ClaudeSessionID,
ParentSessionID: info.ParentSessionID,
Status: string(info.Status),
Query: info.Query,
Summary: info.Summary,
Title: info.Title,
Model: info.Model,
WorkingDir: info.WorkingDir,
CreatedAt: info.StartTime,
LastActivityAt: info.LastActivityAt,
CompletedAt: info.EndTime,
ErrorMessage: info.Error,
AutoAcceptEdits: info.AutoAcceptEdits,
DangerouslySkipPermissions: info.DangerouslySkipPermissions,
DangerouslySkipPermissionsExpiresAt: info.DangerouslySkipPermissionsExpiresAt,
Archived: info.Archived,
}
// Copy result data if available
@@ -242,6 +249,9 @@ func (h *SessionHandlers) GetSession(ctx context.Context, req api.GetSessionRequ
// UpdateSession updates session settings (auto-accept, archived status)
func (h *SessionHandlers) UpdateSession(ctx context.Context, req api.UpdateSessionRequestObject) (api.UpdateSessionResponseObject, error) {
// Debug log incoming request
slog.Info("UpdateSession called", "sessionId", req.Id, "body", req.Body)
update := store.SessionUpdate{}
// Update auto-accept if specified
@@ -259,8 +269,30 @@ func (h *SessionHandlers) UpdateSession(ctx context.Context, req api.UpdateSessi
update.Title = req.Body.Title
}
// Update dangerously skip permissions if specified
if req.Body.DangerouslySkipPermissions != nil {
update.DangerouslySkipPermissions = req.Body.DangerouslySkipPermissions
}
// Update dangerously skip permissions timeout if specified
if req.Body.DangerouslySkipPermissionsTimeoutMs != nil {
timeoutMs := *req.Body.DangerouslySkipPermissionsTimeoutMs
if timeoutMs > 0 {
// Convert milliseconds to time.Time
expiresAt := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
expiresAtPtr := &expiresAt
update.DangerouslySkipPermissionsExpiresAt = &expiresAtPtr
} else {
// Clear the expiration if timeout is 0
var nilTime *time.Time
update.DangerouslySkipPermissionsExpiresAt = &nilTime
}
}
err := h.manager.UpdateSessionSettings(ctx, string(req.Id), update)
if err != nil {
// Log the actual error for debugging
slog.Error("UpdateSession error", "error", err, "sessionId", req.Id, "update", update)
if errors.Is(err, sql.ErrNoRows) {
return api.UpdateSession404JSONResponse{
NotFoundJSONResponse: api.NotFoundJSONResponse{

View File

@@ -47,7 +47,7 @@ func TestSessionHandlers_CreateSession(t *testing.T) {
mockSetup: func() {
mockManager.EXPECT().
LaunchSession(gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, config claudecode.SessionConfig) (*session.Session, error) {
DoAndReturn(func(ctx context.Context, config session.LaunchSessionConfig) (*session.Session, error) {
// Validate the config passed to LaunchSession
assert.Equal(t, "Help me write tests", config.Query)
assert.Equal(t, claudecode.Model("sonnet"), config.Model)
@@ -101,7 +101,7 @@ func TestSessionHandlers_CreateSession(t *testing.T) {
mockSetup: func() {
mockManager.EXPECT().
LaunchSession(gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, config claudecode.SessionConfig) (*session.Session, error) {
DoAndReturn(func(ctx context.Context, config session.LaunchSessionConfig) (*session.Session, error) {
// Verify MCP config was properly converted
require.NotNil(t, config.MCPConfig)
assert.Len(t, config.MCPConfig.MCPServers, 1)

View File

@@ -57,6 +57,10 @@ func (m *Mapper) SessionToAPI(s store.Session) api.Session {
session.DurationMs = s.DurationMS
}
session.AutoAcceptEdits = &s.AutoAcceptEdits
session.DangerouslySkipPermissions = &s.DangerouslySkipPermissions
if s.DangerouslySkipPermissionsExpiresAt != nil {
session.DangerouslySkipPermissionsExpiresAt = s.DangerouslySkipPermissionsExpiresAt
}
session.Archived = &s.Archived
return session

View File

@@ -579,6 +579,15 @@ components:
type: boolean
description: Whether edit tools are auto-accepted
default: false
dangerously_skip_permissions:
type: boolean
description: When true, all tool calls are automatically approved without user consent
default: false
dangerously_skip_permissions_expires_at:
type: string
format: date-time
nullable: true
description: ISO timestamp when dangerously skip permissions mode expires (optional)
archived:
type: boolean
description: Whether session is archived
@@ -644,6 +653,16 @@ components:
custom_instructions:
type: string
description: Custom instructions for Claude
dangerously_skip_permissions:
type: boolean
description: Launch session with dangerously skip permissions enabled
default: false
dangerously_skip_permissions_timeout:
type: integer
format: int64
nullable: true
description: Optional default timeout in milliseconds for dangerously skip permissions
default: 900000 # 15 minutes default, but nullable
verbose:
type: boolean
description: Enable verbose output
@@ -693,6 +712,14 @@ components:
auto_accept_edits:
type: boolean
description: Enable/disable auto-accept for edit tools
dangerously_skip_permissions:
type: boolean
description: Enable or disable dangerously skip permissions mode
dangerously_skip_permissions_timeout_ms:
type: integer
format: int64
nullable: true
description: Optional timeout in milliseconds for dangerously skip permissions mode
archived:
type: boolean
description: Archive/unarchive the session

View File

@@ -285,6 +285,12 @@ type CreateSessionRequest struct {
// CustomInstructions Custom instructions for Claude
CustomInstructions *string `json:"custom_instructions,omitempty"`
// DangerouslySkipPermissions Launch session with dangerously skip permissions enabled
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
// DangerouslySkipPermissionsTimeout Optional default timeout in milliseconds for dangerously skip permissions
DangerouslySkipPermissionsTimeout *int64 `json:"dangerously_skip_permissions_timeout"`
// DisallowedTools Blacklist of disallowed tools
DisallowedTools *[]string `json:"disallowed_tools,omitempty"`
@@ -467,6 +473,12 @@ type Session struct {
// CreatedAt Session creation timestamp
CreatedAt time.Time `json:"created_at"`
// DangerouslySkipPermissions When true, all tool calls are automatically approved without user consent
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
// DangerouslySkipPermissionsExpiresAt ISO timestamp when dangerously skip permissions mode expires (optional)
DangerouslySkipPermissionsExpiresAt *time.Time `json:"dangerously_skip_permissions_expires_at"`
// DurationMs Session duration in milliseconds
DurationMs *int `json:"duration_ms"`
@@ -533,6 +545,12 @@ type UpdateSessionRequest struct {
// AutoAcceptEdits Enable/disable auto-accept for edit tools
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
// DangerouslySkipPermissions Enable or disable dangerously skip permissions mode
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
// DangerouslySkipPermissionsTimeoutMs Optional timeout in milliseconds for dangerously skip permissions mode
DangerouslySkipPermissionsTimeoutMs *int64 `json:"dangerously_skip_permissions_timeout_ms"`
// Title Update session title
Title *string `json:"title,omitempty"`
}
@@ -2058,84 +2076,87 @@ func (sh *strictHandler) GetSessionSnapshots(ctx *gin.Context, id SessionId) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/8xc7W/bOJP/VwjdAdsFnNjpy9O7APchTbq7ObTdImnv+bANDEYc23wikSpJOfUV/t8f",
"8FVvlKUkTrLfEmtEDmeGw/nNDPUzSXlecAZMyeT4Z1JggXNQIMx/uCgEX+PsnOj/CMhU0EJRzpLj5MQ9",
"Q+dnySSBHzgvMkiOzTvzH5v/f/tf/51MEqpJC6xWySRhONcElCSTRMD3kgogybESJUwSma4gx3oWtSk0",
"lVSCsmWy3U4SCVJSzmJMXNpHbR70G3N8nRJYHL189frNP/bCyVYTy4IzCUY67zC5gO8lSKX/SzlTwJQT",
"W0ZTrHmc/ktqRn9WzP1MQAgu7CtET/DHh7ODV7OjZJLkICVe6t8+UikpWyLPHVpQyAj65XsJYvOLFUtg",
"9D8FLJLj5D+mlS6n9qmcvteTXTi27SKaInyHiZlFL2M7Sc6ZAsFw9r5i8iHrem3WRUBhmhmhKYFTmFOi",
"LeU6PXr5Sk9ardtPjySINQhkx9zjcnsmmCSfuPqNl4w8fM1Hs5cNXXojZVyhhZlij+u5AMlLkUJ0dCNx",
"v1HN9ha8AKGoNeCU57lbZmxvg/hFIk9T317uMUG3VK1Qikvz2qS9YSZJKgArIHMcmeNUP9NiUTQHqXBe",
"JJNkwUWuiROCFRzoJ7FhacQTfGX0ewnIeyxECTBFFxRE1zs5w4uMbPc36WHZ62GYZVZmGb7WM1qn0p2o",
"ZPPYMk6k5CnVQkOi7Pg1/VZwrZ0xnZ8cGlfu8JkEFtZbdgdXWJVyyFy9rV1a6u0kUZxnc8qK0u4mQqjm",
"CGefa5ZoZdRk+AvnGTLvodqZNKnvPW2aWG/YROToQCzQVOXFVDlH5lbAr/8FqQqcWM//MzaZc4La63or",
"aggIfkBaKpj7aSeRo6o6TP5yp4vVc0M5QZiNDVJnsCG2q8havJyDZ+jsbYIVHqutDuvm5V3zXgZraG3q",
"UghgCtkFIr5AagUNcbIy1zMUwIgW2sTFGEDMMcEokNrElfn5ieXwiqmCXI5fepgMC4E340XxrsxuTkS6",
"omuoRQFNlrB9HtmPX0QJSHHkKCZogTNpfimZ+60ysGvOM8CsucdlbzQkawNP68MFW/7L7nbrBM2fetdf",
"TSrZdRSQU3ZuHx4NSKzO4qQSwaAMh/Ta/HWBaQZk7ibbKYwVVsiSG/kW2lFHpKG96k4RNFc9SWSZpiBl",
"IyJouPugt7aE3ItdkYw1vlPOFGUluEX2G2CW8Vsgc+1OIjI6sY+ReYwyKrUbGi8AXOhtPJcbqSCfF4Ln",
"RTyYAGZEbwmRI4zFC6VUPJ9TJpUoUxVX7KkhQg2iyFiEyoHVnwWK+wogxz/mqhQxLj/iHyjlbA1CujDH",
"0JmNRHPtBKt9RJmCJZgoNE+LecrZgi6HPNjH08+nlnA7SQoQObXbzkrXrDnC1elns1a04AJVL0UFaKBG",
"d4hPcIvMI63R1NmhiQQbp+UnfoswITa+RivMSKZPVsXNiWAHjMYZu43pzzUIQQkM2VJrI9m1jNpJd3ND",
"aYZLAvNm6FVJofZ4nq5oRmJLLrA+M3vHMC9bmr6otey+pX8zM/bFc7tmMy9GJ+v19fVYpyuU2CIf5P3C",
"vnq/dgim5fjc+T4UDONGFmMwbA/Dyp4AKGRFXASk95nZcCnOMjkyADI4hGfu1Bxk6g422GNANbzb8hcW",
"xCJPMIjxxgE40Eqb2587kdGmAB04NpyneaEmPQ+uXaCshev/FiDLTNNaD6F/XlF2o2e+6sWSQVpHL1+9",
"rmE6ytQ/XicxR02lBgJFBsqHdwus5z02gVwbzfxzBWoFNVNAKyyRgBR0bIQCz92Az+0bs7RSQtSePxsa",
"O3gpAZ2fGbtjILWJe8vrug2eQb/K9VP0Qo/jhG2VIH+tqaGUBl5jKalUmNWkfhV1Od9LYCnEYjX7BLEy",
"vwaBKGuov36wvIkpY6cz60f7FmSRHjxI2ZrbxI8W6Iuwkysx9AyoUdvc54qaA//v5Z+fkKU34KgCuWF8",
"Y8yDk+zAsfrRXYezBjjv9QMOIGuiXb6gPtaCi37ZGqbOz5BaUenHpcZbjoPVTTTt7arhWBqeaegU2ROq",
"7B5M94aXJj0GFc7vCfD78kgXJnlkj58WAh+ZTdp34uYu+ZhP2oRd8kA9Rm4mhCp3yLm0NXK3QHFnQGKH",
"bkcjrawlg9sxIVl9ogeEWIajB8LLf66oAg2qtC4bUKsJvwVgMl/QTCvhVlAF9p+r/WPRL/BDmezI42NS",
"s/dOTex1P3j6LsPpjZceaWHVpgDb5n+1PxCrsWocyFYx0+yRUG3OCcRArP7ZpHIkBA/nToRacMILk2KV",
"nDFQ0YDkgajZGc1dwPM5o4rizAHohoVUu/0PyAqUAzJbAWH0eaNWnDnMrNddCJ6ClOj08v+Q3inyEYH0",
"JFmDuOYShoPc90zDFOToES+VdqWxoPaWCx2QzwkVEa9hHyJCBaSKOzm1dByENV3xHKY6EJ0Wghv39YBk",
"QNPr3c3D9x3F3rn31HMY3I6C6PFBdxVzRh4YMQx//4PjDFJKhuOW3tLjn4UNM3zhEb2oSuBcIAJs82tj",
"qR84v5FI4gWErQHRNAuBlBrz6QftgaTyIg6dW3C+ibiRtmD8EGOEczcDC7Xm1rYzGTYP0ujC5bqjFvV8",
"GWvD5ZnpA4hZA4G+heln6AUcLg8nyFbXj5oGUJXcIyoPfQfjA9iTQOmSlwbw/FCxGDYU+du8/1HmmB3o",
"wMb4RKjrqMF9tzlgyMKMsKqpe4Xdb17BkAY7D5zC2izYAaIzx9Nx3qDHa8EMdCALSOmCpsgMEAMRoRLf",
"NZ+1SYrcubvAp6V2CkeP/UUTtkXjsGd92v49EUbpzYK507md/2JwO68BoRDzh7xhdXrYROQ8XWG2NA/q",
"wdzcVsMa9KA0zqreiEVOv9EMLhku5IpHnXtPGkG/5vMHCCsk3RCoTxf3SS7qiGhuGr0iiTK18iUIhzd2",
"RRLTHFN2WGwelDsy5cfUH9deZvWJQ25vzGnt562vs0rgDiY9/gCcqVW/a6jy2iGMvtEDVdzym54g0Z+t",
"Fens8OhwNhx/+GYIP0aMb9O2JcpC3TM4u2eGsCsO6hlxCeVqqMaTxzh8400k9z+SK6zVEVeeFpemP27H",
"2TkI5OwISadl7SMujGuzDXgmXWlLiQu6LEUn4/vTWLrLK5umiqXU6zowx+sBZ5kJy6puoDwtDuzgB7U3",
"t9uYoGJCcXxHujmWMehv50VYLMvctNE2oHmdy0nNx9wNo4fF9c2uOHJJgIZriYsiVpFh612ajkRWTeS3",
"poIzE6+vsaA65mk1a529f/f1d+3sRAnJdshma4mMjoYuIAWmPjsH31RRhqXSrjQiqQ9Y2nPGJp2140S3",
"WCJDPTY2iB8rZwGpOne861CR03yDiyI2eqkDunnKy9jZ+cmWRvjChjSe7yoLE6mMtKTquKuE1Jxyt7D3",
"1XlVU9+9k+PuEBhquBpTkfMAmkoUXo6lLHCp+BynKRRqDoQqOX4KTe46TLAApEc6sCP1zBUt6Lb2vSH5",
"RSJa9TNHMwGjqr+ujhltevUAxVGN6tgdLllzY4LR8EkZ8C8Vogx9vWysZnY4e1ObcpFx0zjZM50tJg71",
"IYf13b8fmbhTa57396EhT6SXldMsoxJSzkjDTb5+M5vNetdTS6gaADbvRZ+dlIA3jv7UwI6e6rBF4i3V",
"scsWneGNz8GpomuqNlE9GP/sKe6hhJ3JYu3tXBqRymge0aWJx7bnRAvw1RbUMjdTabeiJ76pcnVd4e1s",
"7hmVSTYgQyosNMjoS5X6vLKABdbHleXQpRNGd6g7oxBlr0EMdKmPaiR3u6bqI5dlnuOYIE7OD5bAQFh8",
"Zal87TAmhQu3eiDaE6/0ChxSyzkps3hKgKpYq8RXCeJAe3eT4/Hat8T1KT9u0HlecKEwU+gLljdxQKlw",
"Nlf8BmKVGOsW7dPI0T/Oazxv2r3VD+9Rn7XvVit8x13siAIe1gPvQ4m7xh5364DvVqfMZrVIUZSM2b+q",
"jqJJElx1C1eGf83DW0z1752ydWVXvg96T+FbkNe9YzeXAtkXQ41U1L25+mryYIP17t5+/pN2u31L7aMD",
"ym5BbUqoNA6mFjiavVnFldEJ+pyWWekOb2UJiHFU6BOOnbYR/KZDCLbgPv2HUyM+d9HSZMM/4A0IdFkW",
"2hNq6CGy5DhZKVXI4+l0pUkyTXJIYN2FmBfvL7+gk8/nZuW18QiGnDOktWS8uJygQvA1JdqX+UXmmOEl",
"aHg6+cZCk4V2h4uM38oJ0hBaAM5MdGGzrUgqATjXw6S4wNc0o9oMDr8ZdVrZ1hd2ZhnxfNbyWcfJ0eHs",
"cKbXxAtguKDJcfLK5cY0JjOqn3q+zH9LiIVIVIdInn3XEyPtLTzu62Yh1KOZAmF9RpDOOXHDhPs0tjU3",
"3Pn9K5KyVSDQ9aYJMMxtWu+6nZare7q7btFetW7RvpzNRly5HHdbsntLKHJj8oNvSAki2E6SN5aL2OCB",
"22nzbuy2HpX06EabCrbpqkriVzqo5LLvTiQgjBjcdgYzlm+2CRKwpnDbUWyzQ8ndbQap3nGy2ZuM441p",
"26an1RHItqPoo0djol/bobbr4gut7NdjlF273b0P+/CqbSm1x0C2k5o/mP6kZNvrFH4HhWydEwjSLliD",
"Jb1P8TUvFcIo1NAiczft53dQNeNpuYXY0iuSae1TAU+yxUfp3Nd/jc5fDysw3AHfh8a1YnCbk7HqnhLT",
"KmBCkKircHewkeuJQJgN67fZfvBwFe/fucS7R0Y5l9mjMdFvaGeu2QMJSLkgDe+yF1ZGfM5gjTNKQueK",
"todgBzgTgMkGWVsiz7MNrDQRZ3fxfStTpuz1eacrSG9sjgV8BEglckDKRHN2hE3Mx9kaaPKIFtSqskb0",
"dgliTVPQXHtOm2KzQ6BUr7QmqEt3vcJISZhU+kEIIaOyugAlKKwBWepsY5Nhty24T8F2rH4vaXqDsK84",
"doRXKwgMhY6+e5SFyoXhFCmOBKhSsJ44MqM5VY0YMuTZX85Mv6prNZ0NNJ4+6kEUq4xEv8+hyezK93as",
"WFXGdFg3FX812hpL/ab0DnSRBQTRBhYBUByid9qnGJU4TUrEWbZBGeCQa5bf2IvmSIwjc7FQAPv1EF2C",
"MvR/smzzP+H2+xKaPFi41cUvYXEDNnhh2Itwh17U2emzRMdf3Bj7ivfdVG2alQRCfanigTJ3+UX2MEDt",
"qydVZSrChys+DTNSF0aHmR4OPF2/GPqmf8zN10lq7cB5YYF7g3k1kUU22xC4Y8Rm6h3Ms5U8dMpJPV8U",
"A3aX4enj4bpWBuxZYF07rxs9PmtFO5P3NwfWosyyzXMhvA+4ZOnKabWW+dvtjqf+8xz9kb5LKHJRfRsE",
"5WWmaFFVHYwvwUhStsygyoZ1LKn2xY2aC30Me4p8H+WJ4/jY10ViH0Ars5tKYqjKwW8nycvZ26dm5zMW",
"prDnu76eyZqNVDofkRlwfQ3D3lPWos8n/g6qcoh3A7JVovIpDqkxfuzZExWyxUjfyYZVuhqsKfgWYm3D",
"K4Rlo3KR64NOo9QQgJhK2eE3pkMMr3f/xUMdOmYZugb33R4SCwgbxZsHW8P+XWG0uPTEzvAOxugkHTlU",
"n9oye+xqpPOZ+q/T9J+tjYR7KImZjm33rkQLwXOEGYIf1N5AdnSTb4yyFQhTgEVUyeY1yRWViotNzF5b",
"35z5G1psz/elnjoc7Pk2T8R2P9X018j0P7XJep61i1twcaOPstGxoLHaUODvN9tLYESbZCBFki6ZaRBB",
"OKTBvJ2iFJfS2ihS/BvzEQ5aCpyC2d4xK2233P9dj9neqwE7XFytiaIPOzxN/rZ+/YuySnUKK3geAw7i",
"7FrSWAt2jYcjcpLmIk6ZZVHXafKRuDJjmwyhbPmN+RkmtY8r2Sq+qr5UEk0eVWHjR8/l39Suo98niZhQ",
"nQ4F0T9bJJlG2RlpOf5+1AjTWVCNfj09SnGhSgEEkdJ8XafWfjNBcsVvjd2YX/XeQnxhr86ba2geaxSc",
"MmWgtKI57Daf0Mf0t4UfnUariPH81pDi81lNU5s7zMU05Uzt94yS45/2+/DuplIn+/uBpzjzBSJL1mg8",
"Op5OM02y4lIdv3379u0UF3S6PjKKcRx0zl57I9NWbXw2T5USASPWfqrkqavWdDOx3u1ndAHpJs2g1qJU",
"e73KXMbvF1N2oFZwkHFeoG5bUzXQSa3VpbuhetqeqtffW2lvr7b/DgAA//90wlmZL2AAAA==",
"H4sIAAAAAAAC/8xdbW/buJP/KoTugO0CTpz04d/7B7gXadLdzaFPSNrbF9vAYKSxzY1EqiTlxFf4ux/4",
"KEqiLCVxkt1XjUWRw5nhPPxmqP2ZpKwoGQUqRXL0MykxxwVI4PovXJacrXB+lqm/MhApJ6UkjCZHybF9",
"hs5Ok0kCt7goc0iO9Duz2/X/vf2vfyeThKihJZbLZJJQXKgBJEsmCYcfFeGQJUeSVzBJRLqEAqtV5LpU",
"o4TkhC6SzWaSCBCCMBoj4sI8atOg3pjhqzSD+eHLV6/f/GsnlGzUYFEyKkBz5x3OzuFHBUKqv1JGJVBp",
"2ZaTFCsap38LRejPmrifCXDOuHklUwv88eF079XBYTJJChACL9RvH4kQhC6Qow7NCeQZ+uVHBXz9i2GL",
"J/Q/OcyTo+Q/prUsp+apmL5Xi51bss0mmix8hzO9itrGZpKcUQmc4vx9TeRD9vVa7ysDiUmumSY5TmFG",
"MqUpV+nhy1dq0XrfbnkkgK+AIzPnDrfbs8Ak+cTkb6yi2cP3fHjwsiFLp6SUSTTXS+xwP+cgWMVTiM6u",
"Oe4Oqj7enJXAJTEKnLKisNuMnW3gvwjkxoTHyz7O0A2RS5TiSr82aR+YSZJywBKyGY6scaKeKbZIUoCQ",
"uCiTSTJnvFCDkwxL2FNPYtOSiCX4RsmPCpCzWIhkQCWZE+Bd62QVLzKzOd9ZD8lODsMk0yrP8ZVa0RiV",
"7kIVncW2cSwES4liGuJVx66pt7xp7cxp7eTQvGKLzcxgbqxld3KJZSWG1NXp2oUZvZkkkrF8RmhZmdOU",
"ZURRhPMvgSYaHjUJ/spYjvR7KPBJk/DsKdXE6sAmvEB7fI6msiin0hoyuwN29Tek0lNiLP/P2GLWCCqr",
"67SowSC4hbSSMHPLTiKuqnYmf1nvYuTcEI5nZuOAhAQ22HYZ2Yvjs7cMnbOdYYnHSqtDun5527oXXhta",
"h7riHKhEZoOIzZFcQoOdtCrUCiXQTDFtYmMMyLSboASyYOFa/dzCYnjHREIhxm/dL4Y5x+vxrHhX5dfH",
"PF2SFQRRQJMkbJ5HzuNXXgGSDNkREzTHudC/VNT+VivYFWM5YNo846I3GhLBxNNwOq/Lf5nTboyg/qc6",
"9ZeTmncdARSEnpmHhwMcC0mc1CwY5OGQXJu/zjHJIZvZxbYyY4klMsM1f0tlqCPcUFZ1Kwuau54kokpT",
"EKIRETTMvZdbm0P2xS5LxirfCaOS0ArsJvsVMM/ZDWQzZU4iPDo2j5F+jHIilBkazwBcqmM8E2shoZiV",
"nBVlPJgAqllvBiI7MBYvVEKyYkaokLxKZVywJ3oQagyKzJURMbD7Uz/ivgwo8O1MVjxG5Ud8i1JGV8CF",
"DXP0OH2QSKGMYH2OCJWwAB2FFmk5Sxmdk8WQBft48uXEDNxMkhJ4QcyxM9zVe45QdfJF7xXNGUf1S1EG",
"6lSjO8UnuEH6kZJoavVQR4INb/mJ3SCcZSa+RktMs1x5Vsm0RzATRuOM7cr0eQWckwyGdKl1kMxeRp2k",
"u5mhNMdVBrNm6FVzIXg8S5ckz2JbLrHymb1z6JfNmL6oteq+pX7TK/bFc9tW0y9GF+u19WGs02VKbJMP",
"sn7+XL1f2QymZfisfx8KhnEDxRgM2/20oicA8qiIjYDUOdMHLsV5LkYGQDoPYbn1moNE3UEHexQoyHdb",
"9sIkscgNGMzxxiVwoIQ2Mz93IqN1CSpwbBhP/ULAPZdc20BZMdf9m4OocjXWWAj185LQa7XyZW8u6bl1",
"+PLV6yCnI1T+63USM9REqESgzEG68G6O1bpHOpBrZzN/LkEuIVAFtMQCcUhBxUbI09wN+Oy50VurBET1",
"+YseYyavBKCzU613FIRScad5XbPBcugXuXqKXqh5LLONEMSvgRgqodNrLAQREtOA65dRk/OjAppCLFYz",
"TxCtiivgiNCG+EPH8iYmjK3GrD/bN0lW1pMPErpiBvhRDH3hT3LNhp4JVdY2c1hRc+L/ufj8CZnxOjmq",
"k1w/v1bmwUW25LHq0V2nMwo467UDNkFWg7bZgnCuOeP9vNVEnZ0iuSTCzUu0tRyXVjezaadXDcPSsExD",
"XmRHWWXXMd07vdTwGNR5fk+A34cjnWvwyLifVgY+Ek3aNXBzFzzmk1JhCx7Ix8BmfKhyB8ylLZG7BYpb",
"AxIzdTsaaaGWFG7GhGThQg8IsTRFD0wv/1wSCSqpUrJspFrN9JsDzmZzkish3HAiwfxxuftc9CvcSo2O",
"PH5Oqs/eiY69oukppgvgrBL5eiauSTkLs7HBeOIDrmi69FCuxuGDGZGaMczvEFAVQmbREGMbKTMVwrFK",
"Nkj694H6r03T59IYCGTHIfuq8uYFyXMiIGU0M4zZRmwSCcB6guAgBhjO99/lOL126pi1kv+mRrbtyeXu",
"UAGV/MeRgToIPXgkmKBgGcRQAfWzxsYEeJdhdSuI9lipMWvBKAUZjfAeCEPYU3gXNOKMEklwbhGJxpGr",
"zecfkJeoAKRtC8Loy1ouGbUghNp3yVkKQqCTi/9FyvSIR0QmJskK+BUTMHzK3+tDi+x4xCqpfFPsCN8w",
"rjKcWUZ4xAybhygjHFLJLJ9aMvbMmi5ZAVMV2U9LzrQ/eAC60nQjd3OZfbGN85Y9BTIKN6Mwj/ik26pj",
"Iz1wDBS5vyc+hZRkw4Fgby3Xm2U7Ar2oewqUIQa6/rWx1Q+MXQsk8Bz80YAobpVBSrT69KMgfkhtRSzc",
"YdCOdcSMtBnjphjDnLspmC/et46dhixd1kvmtngQ1ajnKwFoKk91Y0VMGzLo25h6hl7A/mJ/gky7wmFT",
"AeoehojIfSPH+Izg2I+0aLDOIG9lLCnwXRNt2v+oCkz3VKSobSKEMmpQ3+22GNIwzax66V5m96uXV6TB",
"Vg4rsDYJZoLoynF80yn0eCnoifZECSmZkxTpCWJZmW9t6KrPSqNMd27XcDjfVuaoub+qgW3W2GQ+XLb/",
"TPhZemFF653bgCKFm1mQWfokygOxtfcwyO4sXaoQVsPdQTA3M+XFxniQKnGt34hFTr+RHC4oLsWSRY17",
"Dy6jXnOADMISCTsF6pPFfdBaFRHNdOdcBHmUS1fTsQnctkhiWmBC98v1g8A4Xc9Nnbt2PAsX9mDpGG/t",
"1g33WSPigyjSH4Bzuew3DXWhwIfR12qimlp23RMkOt9aDz3YP9w/GI4/XHeJmyNGt+6D41Up7xmc3RNy",
"7bKDOEIsQl9P1XjyGM433pVzf5dc51oddhVpeaEbDrf4zsFEzsyQdHoAP+JSmzbT0ajxX1ObnZNFxTsQ",
"+k+t6Rao110qC6H2tafd6x6juQ7L6vaqIi33zOR7wZubTYxRMaZYuiPtMYsYlmLWRZgvqkL3JTdS85DK",
"SWBj7paj+831rS4ZsiBAw7TEWRErcdHVNklHIqtm5rcinFEdr68wJyrmaXW/nb5/9+13Zex4BclmSGcD",
"IKMjoXNIgcov1sA3RZRjIZUpjXDqAxbGzxgUXxlOdIMF0qPHxgZxt3LqM1Vrjrc5FTEt1rgsY7NXKqCb",
"payK+c5PptbE5iakcXTXKEyk1NTiqqWuZlJzye3M3lUrWyC+e1cbrBMY6mAbU+J0CTQRyL8cgyxwJdkM",
"pymUcgYZkWL8Emq4bdnBHJCaac/M1LNWtELeOvd6yC8CkbpBPIoEjCqn28JwtIvYJSh21KgW6OEeAKZV",
"MBo+SZ38Cw3Efrto7OZg/+BNsOQ8Z7oTtWc5U50dauz2+7t/g/fDwPE/l0CRJhzhPA/aL7y2FFgS9csa",
"4bCVnVVSWQGdoIpGiXMsWg63JeEgonw5u/hcswLdKCK3QvaFytPthOgFszDOr/dWkcyGArOiv1sSuUFt",
"0D5UmtdvNPo/DMnrrHbWm9J3cBZ34vrxli2d/97uxBv/Y1eCOtNrQ45TSVZErqNC1E7PjbiHZm9F4JUL",
"sdgsEVFw1mLvY5vIom0itV1TPNdLKVutFr6uAdAu87a2oI2C53XmJiTmKnPrw58dWM9hjlUMYCi0GM3o",
"exRWKXjVqxADdylGXXewp6a+7SCqosAxRhyf7S2AAjdJqxnlKtwxLpzb3UOmDNZS7cCmvwXLqjyOsxAZ",
"a+j5JoDvKZepgTMnfTM4XPLjGp0VJeMSU4m+YnEdz9IlzmeSXUOsvGV8jXkaiafGWY3nrWW0bm24VNro",
"d+vCRsdcbAmtHnZTw8Vndw3o7nZPo1vy04fVpN+8otT8q+57myTeVLeSdf+nfniDifq901xR65Xr1t9R",
"TOz5de+A2OJKuyKoge/dm6pvGlwc7MrovXVy3L4U0hL76Ci9W6WcZkRoAxNE4/ps1sH6ncOpvrUQ48gt",
"NxhC3bvlIRon+WrafZsbHE336HDos+9aKbYYdjMg0zYdfcKxwCSCH6hoi86Zg59xqjXN3pzW1ZgPeA0c",
"XVSlchoq9eV5cpQspSzF0XS6VENyNWQ/g1UX4jh/f/EVHX850xwL5sswFIwipdDa4YkJUiE6yZTZd5ss",
"MMULKIDKyXfqu6aU55jn7EZMEKYZ4oBzHYgZtB8JyQEXapoUl/iK5ESdmP3vWvMNb8ONnRpCHJ0BnnqU",
"HO4f7B+oPbESKC5JcpS8sthsqVJ7xaqpo0v/tYBYNElUNOnIt01uwrTzuIC/jopJLoEb8+q5c5bZafwF",
"OdNr7y/x/xUpGUjg6GrdTHD19Xjn5ayU64v3267FX7auxb88OBhxh3rc9efutb/IFegPrsPMs2AzSd4Y",
"KmKTe2qnzcvumzCA65GNUhVs4NKa45cq/mai75IzIIwo3HQm05qvjwnisCJw0xFss+XQfqwAhHzHsvXO",
"eBzvNN00nZKySZuOoA8fjYh+afveAhuKKWG/HiPs4HMNu9APJ9qWUHsUZDMJ7MH0J8k2vUbhd5DI1Nkh",
"Q8oEK0ehzim+Ug4HI1/Djazd1J/fQQbK0zILsa3XQ6bBtz+e5IiPkrnrP9Ayfz0sQP9Rh11IXAkGtykZ",
"K+5ppltVdLQWNRX2owrI9uQgTIfl22x/ebiId29c4t1Lo4zLwaMR0a9op7bZCHFIGc8a1mUnpIz4PskK",
"5yTznVNKH7we4JwDztbI6FL2PMfAcBMxehfbt9Rl8l6bd7KE9NrAUeAiQCKQzTl1NGdmWMdsnKnBJ4+o",
"Qa0qf0RuF8BXJAVFtaO0yTYzBUrVTgNGXdj7UppLXJdy9nwIGeXVOUhOYAXIjM7XBje8aSEjBEwy8qMi",
"6TXCruLdYV5QkBoKHV33MvWVM00pkgxxkBWnPXFkTgoiGzGkB/BfHuh+advqfDDQ+PyojihWmYt+cEcN",
"MzvfmVsxoozJMFQV960Doyzhpw+2ZBe5zyDaiYVPKPbRu7Xv2jeSFIjRfI1ywB6WF9/pi+ZMlCF9U5gD",
"/XUfXYDU4z/TfP3f/nMWC2jSYNKtbv7iNzegg+eavAh16EVITp8mWvriytjXPNJFtdO8ysDXN2saCLW3",
"2UQPAcS8elxXRiN02KrWMCEhMzrE9FDgxvWzoW/5xzx8HfxvS57nN7izNC9gWeSwDSV3NDNFDZvmmUoy",
"OmFZCK3FErsL//Tx8roWWPgsaV0bAo+6z6BorEsk2mHNqzxfP1eGZy9ZGakGIOl2czx139vpj/Qt9sp4",
"/bEfVFS5JGVdoNG2BCNB6CKHGg3raFLwCZ3AhD6GPkU+ePTEcXzsc0GxLxpW+XXNMVSXKzaT5OXB26cm",
"5wvmugbqug6fSZs1VzpfhRowfQ3F3hFq0WcTfwdZG8S7JbI1UPkUTmqMHXt2oEK0COnzbFimy8Gagmth",
"Vzq8RFg0ijy6T0VlqT4A0UXF/e9UhRhO7u4Tpip0zHN0BfZDXFksIGzUuR6sDbs3hdE63BMbwzsoo+V0",
"xKk+tWb26NVI4zN1n5vq960NwN2XxPSNAfuuQHPOCoQpgltiPilgx02+U0KXwHWtGhEpmtd0l0RIxtcx",
"fW19ROofqLE9H4x76nCw52NbEd39FMivgfQ/tco6mpWJmzN+rVzZ6FhQa63vhehX2wugmVJJPxQJstBl",
"Zoawh8GcnqIUV8LoKJLsO3URDlpwnII+3jEtbV/5+Ke62d6rKVtMXNBv0pc7PA1+G14/JLQWncQSnkeB",
"PTu7mjRWg22P5ghMUl8Eq/I8ajo1HolrNTZgCKGL79StMAnadU0VX9afHoqCR3XY+NFR+Q/V6+gHhyIq",
"FI5DnvXPFkmmUXJGao67nzdCdeZEZb9uPEpxKSsOGcoq/bmsoFNpgsSS3Wi90b+qs4XY3Hy6QV+DdLlG",
"yQiVOpWWpIDt6uNbvv6x6UenJy2iPL81uPh8WtOU5hZ10U05U/OBsuTop/kfPtibch309wNLce4KRGZY",
"o/HoaDrN1ZAlE/Lo7du3b6e4JNPVoRaMpaDje82NYFO1cWierAQCmhn9qcFTW63pIrHO7OdkDuk6zSFo",
"UQper5HL+P12QvfkEvZyxkrUbWuqJzoOWl26B6qn7al+/b3h9uZy8/8BAAD//3ZyehIAZAAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -37,10 +37,30 @@ func (m *manager) CreateApproval(ctx context.Context, runID, toolName string, to
return "", fmt.Errorf("session not found for run_id: %s", runID)
}
// Check if this is an edit tool and auto-accept is enabled
// Check if auto-accept is enabled (either mode)
status := store.ApprovalStatusLocalPending
comment := ""
if session.AutoAcceptEdits && isEditTool(toolName) {
// Check dangerously skip permissions first (overrides edit mode)
if session.DangerouslySkipPermissions {
// Check if it has an expiry and if it's expired
if session.DangerouslySkipPermissionsExpiresAt != nil && time.Now().After(*session.DangerouslySkipPermissionsExpiresAt) {
// Expired - disable it
update := store.SessionUpdate{
DangerouslySkipPermissions: &[]bool{false}[0],
DangerouslySkipPermissionsExpiresAt: &[]*time.Time{nil}[0],
}
if err := m.store.UpdateSession(ctx, session.ID, update); err != nil {
slog.Error("failed to disable expired dangerously skip permissions", "session_id", session.ID, "error", err)
}
// Continue with normal approval
} else {
// Dangerously skip permissions is active (no expiry or not expired)
status = store.ApprovalStatusLocalApproved
comment = "Auto-accepted (dangerous skip permissions enabled)"
}
} else if session.AutoAcceptEdits && isEditTool(toolName) {
// Regular auto-accept edits mode
status = store.ApprovalStatusLocalApproved
comment = "Auto-accepted (auto-accept mode enabled)"
}

View File

@@ -18,9 +18,19 @@ const (
// EventConversationUpdated indicates new conversation content has been added to a session
EventConversationUpdated EventType = "conversation_updated"
// EventSessionSettingsChanged indicates session settings have been updated
// Data includes: session_id, run_id, changed settings, and optional "reason" field
// For dangerous skip permissions expiry: reason="expired", expired_at=timestamp
EventSessionSettingsChanged EventType = "session_settings_changed"
)
// SessionSettingsChangeReason represents reasons for session settings changes
type SessionSettingsChangeReason string
const (
// SessionSettingsChangeReasonExpired indicates dangerous skip permissions expired due to timeout
SessionSettingsChangeReasonExpired SessionSettingsChangeReason = "expired"
)
// Event represents an event in the system
type Event struct {
Type EventType `json:"type"`

View File

@@ -37,17 +37,29 @@ func getShutdownTimeout() time.Duration {
return 5 * time.Second // default per ENG-1699 requirements
}
// getPermissionMonitorInterval returns the interval for dangerous skip permissions expiry checks
func getPermissionMonitorInterval() time.Duration {
if intervalStr := os.Getenv("HLD_PERMISSION_MONITOR_INTERVAL"); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil {
return interval
}
slog.Warn("invalid HLD_PERMISSION_MONITOR_INTERVAL, using default", "value", intervalStr)
}
return 30 * time.Second
}
// Daemon coordinates all daemon functionality
type Daemon struct {
config *config.Config
socketPath string
listener net.Listener
rpcServer *rpc.Server
httpServer *HTTPServer
sessions session.SessionManager
approvals approval.Manager
eventBus bus.EventBus
store store.ConversationStore
config *config.Config
socketPath string
listener net.Listener
rpcServer *rpc.Server
httpServer *HTTPServer
sessions session.SessionManager
approvals approval.Manager
eventBus bus.EventBus
store store.ConversationStore
permissionMonitor *session.PermissionMonitor
}
// New creates a new daemon instance
@@ -179,6 +191,16 @@ func (d *Daemon) Run(ctx context.Context) error {
// Don't fail startup for this
}
// Create and start dangerous skip permissions monitor
permissionMonitor := session.NewPermissionMonitor(d.store, d.eventBus, getPermissionMonitorInterval())
d.permissionMonitor = permissionMonitor
// Start dangerous skip permissions monitor in background
go func() {
permissionMonitor.Start(ctx)
}()
slog.Info("started dangerous skip permissions expiry monitor")
// Register subscription handlers
subscriptionHandlers := rpc.NewSubscriptionHandlers(d.eventBus)
d.rpcServer.SetSubscriptionHandlers(subscriptionHandlers)

View File

@@ -0,0 +1,195 @@
package daemon
import (
"context"
"testing"
"time"
"github.com/humanlayer/humanlayer/hld/bus"
"github.com/humanlayer/humanlayer/hld/session"
"github.com/humanlayer/humanlayer/hld/store"
)
func TestDaemon_PermissionExpiryMonitor(t *testing.T) {
// Create in-memory SQLite store
tempStore, err := store.NewSQLiteStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer func() { _ = tempStore.Close() }()
// Create event bus
eventBus := bus.NewEventBus()
// Create monitor with short interval for testing
monitor := session.NewPermissionMonitor(tempStore, eventBus, 100*time.Millisecond)
// Create a session with expired dangerous skip permissions
expiredTime := time.Now().Add(-1 * time.Minute)
session := &store.Session{
ID: "test-session",
RunID: "test-run",
Query: "test query",
Status: store.SessionStatusRunning,
DangerouslySkipPermissions: true,
DangerouslySkipPermissionsExpiresAt: &expiredTime,
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
}
err = tempStore.CreateSession(context.Background(), session)
if err != nil {
t.Fatal(err)
}
// Subscribe to events
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
Types: []bus.EventType{bus.EventSessionSettingsChanged},
})
// Start monitor in background
monitorCtx, monitorCancel := context.WithCancel(context.Background())
go monitor.Start(monitorCtx)
// Wait for event
select {
case event := <-subscriber.Channel:
// Verify event data
if event.Data["session_id"] != "test-session" {
t.Errorf("wrong session_id in event")
}
if event.Data["reason"] != string(bus.SessionSettingsChangeReasonExpired) {
t.Errorf("expected reason=%s, got %v", bus.SessionSettingsChangeReasonExpired, event.Data["reason"])
}
case <-ctx.Done():
t.Fatal("timeout waiting for expiry event")
}
// Verify session was updated
updatedSession, err := tempStore.GetSession(context.Background(), "test-session")
if err != nil {
t.Fatal(err)
}
if updatedSession.DangerouslySkipPermissions {
t.Error("dangerous permissions should be disabled")
}
if updatedSession.DangerouslySkipPermissionsExpiresAt != nil {
t.Error("expiry time should be cleared")
}
// Clean shutdown
monitorCancel()
}
func TestDaemon_PermissionMonitorWithMultipleSessions(t *testing.T) {
// Create in-memory SQLite store
tempStore, err := store.NewSQLiteStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer func() { _ = tempStore.Close() }()
// Create event bus
eventBus := bus.NewEventBus()
// Create monitor with short interval for testing
monitor := session.NewPermissionMonitor(tempStore, eventBus, 100*time.Millisecond)
// Create multiple sessions with different expiry states
expiredTime := time.Now().Add(-1 * time.Minute)
futureTime := time.Now().Add(1 * time.Hour)
sessions := []*store.Session{
{
ID: "expired-session",
RunID: "run-1",
Query: "test query 1",
Status: store.SessionStatusRunning,
DangerouslySkipPermissions: true,
DangerouslySkipPermissionsExpiresAt: &expiredTime,
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
},
{
ID: "active-session",
RunID: "run-2",
Query: "test query 2",
Status: store.SessionStatusRunning,
DangerouslySkipPermissions: true,
DangerouslySkipPermissionsExpiresAt: &futureTime,
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
},
{
ID: "no-permissions-session",
RunID: "run-3",
Query: "test query 3",
Status: store.SessionStatusRunning,
DangerouslySkipPermissions: false,
CreatedAt: time.Now(),
LastActivityAt: time.Now(),
},
}
for _, s := range sessions {
err = tempStore.CreateSession(context.Background(), s)
if err != nil {
t.Fatal(err)
}
}
// Subscribe to events
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
Types: []bus.EventType{bus.EventSessionSettingsChanged},
})
// Start monitor in background
monitorCtx, monitorCancel := context.WithCancel(context.Background())
go monitor.Start(monitorCtx)
// Wait for event - should only get one for expired-session
select {
case event := <-subscriber.Channel:
// Verify event data
if event.Data["session_id"] != "expired-session" {
t.Errorf("wrong session_id in event, got %v", event.Data["session_id"])
}
case <-ctx.Done():
t.Fatal("timeout waiting for expiry event")
}
// Verify only the expired session was updated
expiredSession, err := tempStore.GetSession(context.Background(), "expired-session")
if err != nil {
t.Fatal(err)
}
if expiredSession.DangerouslySkipPermissions {
t.Error("expired session should have dangerous skip permissions disabled")
}
// Active session should still have permissions
activeSession, err := tempStore.GetSession(context.Background(), "active-session")
if err != nil {
t.Fatal(err)
}
// Debug: Check what expiry time we have
if activeSession.DangerouslySkipPermissionsExpiresAt != nil {
t.Logf("Active session expiry time: %v", activeSession.DangerouslySkipPermissionsExpiresAt)
t.Logf("Current time: %v", time.Now())
t.Logf("Time until expiry: %v", time.Until(*activeSession.DangerouslySkipPermissionsExpiresAt))
}
if !activeSession.DangerouslySkipPermissions {
t.Error("active session should still have permissions enabled")
}
// Clean shutdown
monitorCancel()
}

View File

@@ -41,18 +41,20 @@ func (h *SessionHandlers) SetEventBus(eventBus bus.EventBus) {
// LaunchSessionRequest is the request for launching a new session
type LaunchSessionRequest struct {
Query string `json:"query"`
Model string `json:"model,omitempty"`
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
MaxTurns int `json:"max_turns,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
AppendSystemPrompt string `json:"append_system_prompt,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
DisallowedTools []string `json:"disallowed_tools,omitempty"`
CustomInstructions string `json:"custom_instructions,omitempty"`
Verbose bool `json:"verbose,omitempty"`
Query string `json:"query"`
Model string `json:"model,omitempty"`
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
MaxTurns int `json:"max_turns,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
AppendSystemPrompt string `json:"append_system_prompt,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
DisallowedTools []string `json:"disallowed_tools,omitempty"`
CustomInstructions string `json:"custom_instructions,omitempty"`
Verbose bool `json:"verbose,omitempty"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions,omitempty"`
DangerouslySkipPermissionsTimeout *int64 `json:"dangerously_skip_permissions_timeout,omitempty"`
}
// LaunchSessionResponse is the response for launching a new session
@@ -73,20 +75,25 @@ func (h *SessionHandlers) HandleLaunchSession(ctx context.Context, params json.R
return nil, fmt.Errorf("query is required")
}
// Build session config
config := claudecode.SessionConfig{
Query: req.Query,
MCPConfig: req.MCPConfig,
PermissionPromptTool: req.PermissionPromptTool,
WorkingDir: req.WorkingDir,
MaxTurns: req.MaxTurns,
SystemPrompt: req.SystemPrompt,
AppendSystemPrompt: req.AppendSystemPrompt,
AllowedTools: req.AllowedTools,
DisallowedTools: req.DisallowedTools,
CustomInstructions: req.CustomInstructions,
Verbose: req.Verbose,
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
// Build session config with daemon-level settings
config := session.LaunchSessionConfig{
SessionConfig: claudecode.SessionConfig{
Query: req.Query,
MCPConfig: req.MCPConfig,
PermissionPromptTool: req.PermissionPromptTool,
WorkingDir: req.WorkingDir,
MaxTurns: req.MaxTurns,
SystemPrompt: req.SystemPrompt,
AppendSystemPrompt: req.AppendSystemPrompt,
AllowedTools: req.AllowedTools,
DisallowedTools: req.DisallowedTools,
CustomInstructions: req.CustomInstructions,
Verbose: req.Verbose,
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
},
// Daemon-level settings (not passed to Claude Code)
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
DangerouslySkipPermissionsTimeout: req.DangerouslySkipPermissionsTimeout,
}
// Parse model if provided
@@ -336,24 +343,28 @@ func (h *SessionHandlers) HandleGetSessionState(ctx context.Context, params json
// Convert to RPC session state
state := SessionState{
ID: session.ID,
RunID: session.RunID,
ClaudeSessionID: session.ClaudeSessionID,
ParentSessionID: session.ParentSessionID,
Status: session.Status,
Query: session.Query,
Summary: session.Summary,
Title: session.Title,
Model: session.Model,
WorkingDir: session.WorkingDir,
CreatedAt: session.CreatedAt.Format(time.RFC3339),
LastActivityAt: session.LastActivityAt.Format(time.RFC3339),
ErrorMessage: session.ErrorMessage,
AutoAcceptEdits: session.AutoAcceptEdits,
Archived: session.Archived,
ID: session.ID,
RunID: session.RunID,
ClaudeSessionID: session.ClaudeSessionID,
ParentSessionID: session.ParentSessionID,
Status: session.Status,
Query: session.Query,
Summary: session.Summary,
Title: session.Title,
Model: session.Model,
WorkingDir: session.WorkingDir,
CreatedAt: session.CreatedAt.Format(time.RFC3339),
LastActivityAt: session.LastActivityAt.Format(time.RFC3339),
ErrorMessage: session.ErrorMessage,
AutoAcceptEdits: session.AutoAcceptEdits,
DangerouslySkipPermissions: session.DangerouslySkipPermissions,
Archived: session.Archived,
}
// Set optional fields
if session.DangerouslySkipPermissionsExpiresAt != nil {
state.DangerouslySkipPermissionsExpiresAt = session.DangerouslySkipPermissionsExpiresAt.Format(time.RFC3339)
}
if session.CompletedAt != nil {
state.CompletedAt = session.CompletedAt.Format(time.RFC3339)
}
@@ -481,40 +492,85 @@ func (h *SessionHandlers) HandleUpdateSessionSettings(ctx context.Context, param
// Update session settings
update := store.SessionUpdate{
AutoAcceptEdits: req.AutoAcceptEdits,
AutoAcceptEdits: req.AutoAcceptEdits,
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
}
// Handle timeout if dangerously skip permissions is being enabled
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
// Only set expiry if timeout is provided
if req.DangerouslySkipPermissionsTimeoutMs != nil && *req.DangerouslySkipPermissionsTimeoutMs > 0 {
expiresAt := time.Now().Add(time.Duration(*req.DangerouslySkipPermissionsTimeoutMs) * time.Millisecond)
expiresAtPtr := &expiresAt
update.DangerouslySkipPermissionsExpiresAt = &expiresAtPtr
}
} else if req.DangerouslySkipPermissions != nil && !*req.DangerouslySkipPermissions {
// Disabling dangerously skip permissions - clear expiry
var nilTime *time.Time
update.DangerouslySkipPermissionsExpiresAt = &nilTime
}
if err := h.store.UpdateSession(ctx, req.SessionID, update); err != nil {
return nil, fmt.Errorf("failed to update session: %w", err)
}
// If auto-accept was enabled, approve any pending edit tool approvals
if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits && h.approvalManager != nil {
// Get pending approvals for the session
pendingApprovals, err := h.store.GetPendingApprovals(ctx, req.SessionID)
if err == nil && len(pendingApprovals) > 0 {
for _, approval := range pendingApprovals {
// Check if it's an edit tool
if isEditTool(approval.ToolName) {
// Auto-approve it
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, "Auto-accepted (auto-accept mode enabled)")
if err != nil {
// Log but don't fail the whole operation
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
// Update retroactive approval logic
// Change the condition to handle both modes:
if h.approvalManager != nil {
shouldAutoApprove := false
autoApproveComment := ""
// Check which mode is being enabled
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
shouldAutoApprove = true
autoApproveComment = "Auto-accepted (dangerous skip permissions enabled)"
} else if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits {
shouldAutoApprove = true
autoApproveComment = "Auto-accepted (auto-accept mode enabled)"
}
if shouldAutoApprove {
pendingApprovals, err := h.store.GetPendingApprovals(ctx, req.SessionID)
if err == nil && len(pendingApprovals) > 0 {
for _, approval := range pendingApprovals {
// For dangerously skip permissions, approve ALL tools
// For edit mode, only approve edit tools
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, autoApproveComment)
if err != nil {
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
}
} else if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits && isEditTool(approval.ToolName) {
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, autoApproveComment)
if err != nil {
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
}
}
}
}
}
}
// Publish event for UI updates
if h.eventBus != nil && req.AutoAcceptEdits != nil {
// Update event publishing
if h.eventBus != nil {
eventData := map[string]interface{}{
"session_id": req.SessionID,
"event_type": "settings_updated",
}
if req.AutoAcceptEdits != nil {
eventData["auto_accept_edits"] = *req.AutoAcceptEdits
}
if req.DangerouslySkipPermissions != nil {
eventData["dangerously_skip_permissions"] = *req.DangerouslySkipPermissions
if *req.DangerouslySkipPermissions && req.DangerouslySkipPermissionsTimeoutMs != nil {
eventData["dangerously_skip_permissions_timeout_ms"] = *req.DangerouslySkipPermissionsTimeoutMs
}
}
h.eventBus.Publish(bus.Event{
Type: bus.EventSessionSettingsChanged,
Data: map[string]interface{}{
"session_id": req.SessionID,
"auto_accept_edits": *req.AutoAcceptEdits,
},
Data: eventData,
})
}

View File

@@ -56,25 +56,27 @@ type GetSessionStateRequest struct {
// SessionState represents the current state of a session
type SessionState struct {
ID string `json:"id"`
RunID string `json:"run_id"`
ClaudeSessionID string `json:"claude_session_id,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
Status string `json:"status"` // starting, running, completed, failed, waiting_input
Query string `json:"query"`
Summary string `json:"summary"`
Title string `json:"title"`
Model string `json:"model,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
CreatedAt string `json:"created_at"`
LastActivityAt string `json:"last_activity_at"`
CompletedAt string `json:"completed_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
CostUSD float64 `json:"cost_usd,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
AutoAcceptEdits bool `json:"auto_accept_edits,omitempty"`
Archived bool `json:"archived,omitempty"`
ID string `json:"id"`
RunID string `json:"run_id"`
ClaudeSessionID string `json:"claude_session_id,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
Status string `json:"status"` // starting, running, completed, failed, waiting_input
Query string `json:"query"`
Summary string `json:"summary"`
Title string `json:"title"`
Model string `json:"model,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
CreatedAt string `json:"created_at"`
LastActivityAt string `json:"last_activity_at"`
CompletedAt string `json:"completed_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
CostUSD float64 `json:"cost_usd,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
AutoAcceptEdits bool `json:"auto_accept_edits"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
DangerouslySkipPermissionsExpiresAt string `json:"dangerously_skip_permissions_expires_at,omitempty"`
Archived bool `json:"archived"`
}
// GetSessionStateResponse is the response for fetching session state
@@ -136,8 +138,10 @@ type InterruptSessionResponse struct {
// UpdateSessionSettingsRequest is the request for updating session settings
type UpdateSessionSettingsRequest struct {
SessionID string `json:"session_id"`
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
SessionID string `json:"session_id"`
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
DangerouslySkipPermissionsTimeoutMs *int64 `json:"dangerously_skip_permissions_timeout_ms,omitempty"`
}
// UpdateSessionSettingsResponse is the response for updating session settings

View File

@@ -106,13 +106,30 @@ export class HLDClient {
}
// Update session settings
async updateSession(id: string, updates: { auto_accept_edits?: boolean, title?: string }): Promise<void> {
async updateSession(id: string, updates: {
auto_accept_edits?: boolean,
title?: string,
dangerouslySkipPermissions?: boolean,
dangerouslySkipPermissionsTimeoutMs?: number
}): Promise<void> {
// Build request with only defined fields to avoid sending undefined values
const updateSessionRequest: any = {};
if (updates.auto_accept_edits !== undefined) {
updateSessionRequest.autoAcceptEdits = updates.auto_accept_edits;
}
if (updates.title !== undefined) {
updateSessionRequest.title = updates.title;
}
if (updates.dangerouslySkipPermissions !== undefined) {
updateSessionRequest.dangerouslySkipPermissions = updates.dangerouslySkipPermissions;
}
if (updates.dangerouslySkipPermissionsTimeoutMs !== undefined) {
updateSessionRequest.dangerouslySkipPermissionsTimeoutMs = updates.dangerouslySkipPermissionsTimeoutMs;
}
await this.sessionsApi.updateSession({
id,
updateSessionRequest: {
autoAcceptEdits: updates.auto_accept_edits,
title: updates.title
}
updateSessionRequest
});
}

View File

@@ -93,6 +93,18 @@ export interface CreateSessionRequest {
* @memberof CreateSessionRequest
*/
customInstructions?: string;
/**
* Launch session with dangerously skip permissions enabled
* @type {boolean}
* @memberof CreateSessionRequest
*/
dangerouslySkipPermissions?: boolean;
/**
* Optional default timeout in milliseconds for dangerously skip permissions
* @type {number}
* @memberof CreateSessionRequest
*/
dangerouslySkipPermissionsTimeout?: number;
/**
* Enable verbose output
* @type {boolean}
@@ -141,6 +153,8 @@ export function CreateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
'allowedTools': json['allowed_tools'] == null ? undefined : json['allowed_tools'],
'disallowedTools': json['disallowed_tools'] == null ? undefined : json['disallowed_tools'],
'customInstructions': json['custom_instructions'] == null ? undefined : json['custom_instructions'],
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
'dangerouslySkipPermissionsTimeout': json['dangerously_skip_permissions_timeout'] == null ? undefined : json['dangerously_skip_permissions_timeout'],
'verbose': json['verbose'] == null ? undefined : json['verbose'],
};
}
@@ -167,6 +181,8 @@ export function CreateSessionRequestToJSONTyped(value?: CreateSessionRequest | n
'allowed_tools': value['allowedTools'],
'disallowed_tools': value['disallowedTools'],
'custom_instructions': value['customInstructions'],
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
'dangerously_skip_permissions_timeout': value['dangerouslySkipPermissionsTimeout'],
'verbose': value['verbose'],
};
}

View File

@@ -135,6 +135,18 @@ export interface Session {
* @memberof Session
*/
autoAcceptEdits?: boolean;
/**
* When true, all tool calls are automatically approved without user consent
* @type {boolean}
* @memberof Session
*/
dangerouslySkipPermissions?: boolean;
/**
* ISO timestamp when dangerously skip permissions mode expires (optional)
* @type {Date}
* @memberof Session
*/
dangerouslySkipPermissionsExpiresAt?: Date;
/**
* Whether session is archived
* @type {boolean}
@@ -186,6 +198,8 @@ export function SessionFromJSONTyped(json: any, ignoreDiscriminator: boolean): S
'totalTokens': json['total_tokens'] == null ? undefined : json['total_tokens'],
'durationMs': json['duration_ms'] == null ? undefined : json['duration_ms'],
'autoAcceptEdits': json['auto_accept_edits'] == null ? undefined : json['auto_accept_edits'],
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
'dangerouslySkipPermissionsExpiresAt': json['dangerously_skip_permissions_expires_at'] == null ? undefined : (new Date(json['dangerously_skip_permissions_expires_at'])),
'archived': json['archived'] == null ? undefined : json['archived'],
};
}
@@ -219,6 +233,8 @@ export function SessionToJSONTyped(value?: Session | null, ignoreDiscriminator:
'total_tokens': value['totalTokens'],
'duration_ms': value['durationMs'],
'auto_accept_edits': value['autoAcceptEdits'],
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
'dangerously_skip_permissions_expires_at': value['dangerouslySkipPermissionsExpiresAt'] == null ? undefined : ((value['dangerouslySkipPermissionsExpiresAt']).toISOString()),
'archived': value['archived'],
};
}

View File

@@ -25,6 +25,18 @@ export interface UpdateSessionRequest {
* @memberof UpdateSessionRequest
*/
autoAcceptEdits?: boolean;
/**
* Enable or disable dangerously skip permissions mode
* @type {boolean}
* @memberof UpdateSessionRequest
*/
dangerouslySkipPermissions?: boolean;
/**
* Optional timeout in milliseconds for dangerously skip permissions mode
* @type {number}
* @memberof UpdateSessionRequest
*/
dangerouslySkipPermissionsTimeoutMs?: number;
/**
* Archive/unarchive the session
* @type {boolean}
@@ -57,6 +69,8 @@ export function UpdateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
return {
'autoAcceptEdits': json['auto_accept_edits'] == null ? undefined : json['auto_accept_edits'],
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
'dangerouslySkipPermissionsTimeoutMs': json['dangerously_skip_permissions_timeout_ms'] == null ? undefined : json['dangerously_skip_permissions_timeout_ms'],
'archived': json['archived'] == null ? undefined : json['archived'],
'title': json['title'] == null ? undefined : json['title'],
};
@@ -74,6 +88,8 @@ export function UpdateSessionRequestToJSONTyped(value?: UpdateSessionRequest | n
return {
'auto_accept_edits': value['autoAcceptEdits'],
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
'dangerously_skip_permissions_timeout_ms': value['dangerouslySkipPermissionsTimeoutMs'],
'archived': value['archived'],
'title': value['title'],
};

View File

@@ -60,15 +60,18 @@ func (m *Manager) SetApprovalReconciler(reconciler ApprovalReconciler) {
}
// LaunchSession starts a new Claude Code session
func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionConfig) (*Session, error) {
func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig) (*Session, error) {
// Generate unique IDs
sessionID := uuid.New().String()
runID := uuid.New().String()
// Extract the Claude config (without daemon-level settings)
claudeConfig := config.SessionConfig
// Add HUMANLAYER_RUN_ID and HUMANLAYER_DAEMON_SOCKET to MCP server environment
if config.MCPConfig != nil {
slog.Debug("configuring MCP servers", "count", len(config.MCPConfig.MCPServers))
for name, server := range config.MCPConfig.MCPServers {
if claudeConfig.MCPConfig != nil {
slog.Debug("configuring MCP servers", "count", len(claudeConfig.MCPConfig.MCPServers))
for name, server := range claudeConfig.MCPConfig.MCPServers {
if server.Env == nil {
server.Env = make(map[string]string)
}
@@ -77,7 +80,7 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
if m.socketPath != "" {
server.Env["HUMANLAYER_DAEMON_SOCKET"] = m.socketPath
}
config.MCPConfig.MCPServers[name] = server
claudeConfig.MCPConfig.MCPServers[name] = server
slog.Debug("configured MCP server",
"name", name,
"command", server.Command,
@@ -90,12 +93,12 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
}
// Capture current working directory if not specified
if config.WorkingDir == "" {
if claudeConfig.WorkingDir == "" {
cwd, err := os.Getwd()
if err != nil {
slog.Warn("failed to get current working directory", "error", err)
} else {
config.WorkingDir = cwd
claudeConfig.WorkingDir = cwd
slog.Debug("No working directory provided, falling back to cwd of daemon", "working_dir", cwd)
}
}
@@ -104,15 +107,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
startTime := time.Now()
// Store session in database
dbSession := store.NewSessionFromConfig(sessionID, runID, config)
dbSession.Summary = CalculateSummary(config.Query)
dbSession := store.NewSessionFromConfig(sessionID, runID, claudeConfig)
dbSession.Summary = CalculateSummary(claudeConfig.Query)
// Handle dangerously skip permissions from config
if config.DangerouslySkipPermissions {
dbSession.DangerouslySkipPermissions = true
// Only set expiry if timeout is provided
if config.DangerouslySkipPermissionsTimeout != nil && *config.DangerouslySkipPermissionsTimeout > 0 {
expiresAt := time.Now().Add(time.Duration(*config.DangerouslySkipPermissionsTimeout) * time.Millisecond)
dbSession.DangerouslySkipPermissionsExpiresAt = &expiresAt
}
}
if err := m.store.CreateSession(ctx, dbSession); err != nil {
return nil, fmt.Errorf("failed to store session in database: %w", err)
}
// Store MCP servers if configured
if config.MCPConfig != nil && len(config.MCPConfig.MCPServers) > 0 {
servers, err := store.MCPServersFromConfig(sessionID, config.MCPConfig.MCPServers)
if claudeConfig.MCPConfig != nil && len(claudeConfig.MCPConfig.MCPServers) > 0 {
servers, err := store.MCPServersFromConfig(sessionID, claudeConfig.MCPConfig.MCPServers)
if err != nil {
slog.Error("failed to convert MCP servers", "error", err)
} else if err := m.store.StoreMCPServers(ctx, sessionID, servers); err != nil {
@@ -125,28 +139,28 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
// Log final configuration before launching
var mcpServersDetail string
var mcpServerCount int
if config.MCPConfig != nil {
mcpServerCount = len(config.MCPConfig.MCPServers)
for name, server := range config.MCPConfig.MCPServers {
if claudeConfig.MCPConfig != nil {
mcpServerCount = len(claudeConfig.MCPConfig.MCPServers)
for name, server := range claudeConfig.MCPConfig.MCPServers {
mcpServersDetail += fmt.Sprintf("[%s: cmd=%s args=%v env=%v] ", name, server.Command, server.Args, server.Env)
}
}
slog.Info("launching Claude session with configuration",
"session_id", sessionID,
"run_id", runID,
"query", config.Query,
"working_dir", config.WorkingDir,
"permission_prompt_tool", config.PermissionPromptTool,
"query", claudeConfig.Query,
"working_dir", claudeConfig.WorkingDir,
"permission_prompt_tool", claudeConfig.PermissionPromptTool,
"mcp_servers", mcpServerCount,
"mcp_servers_detail", mcpServersDetail)
// Launch Claude session
claudeSession, err := m.client.Launch(config)
// Launch Claude session (without daemon-level settings)
claudeSession, err := m.client.Launch(claudeConfig)
if err != nil {
slog.Error("failed to launch Claude session",
"session_id", sessionID,
"error", err,
"config", fmt.Sprintf("%+v", config))
"config", fmt.Sprintf("%+v", claudeConfig))
m.updateSessionStatus(ctx, sessionID, StatusFailed, err.Error())
return nil, fmt.Errorf("failed to launch Claude session: %w", err)
}
@@ -192,10 +206,10 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
}
// Store query for injection after Claude session ID is captured
m.pendingQueries.Store(sessionID, config.Query)
m.pendingQueries.Store(sessionID, claudeConfig.Query)
// Monitor session lifecycle in background
go m.monitorSession(ctx, sessionID, runID, wrappedSession, startTime, config)
go m.monitorSession(ctx, sessionID, runID, wrappedSession, startTime, claudeConfig)
// Reconcile any existing approvals for this run_id
if m.approvalReconciler != nil {
@@ -214,8 +228,8 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
slog.Info("launched Claude session",
"session_id", sessionID,
"run_id", runID,
"query", config.Query,
"permission_prompt_tool", config.PermissionPromptTool)
"query", claudeConfig.Query,
"permission_prompt_tool", claudeConfig.PermissionPromptTool)
// Return minimal session info for launch response
return &Session{
@@ -223,7 +237,7 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
RunID: runID,
Status: StatusRunning,
StartTime: startTime,
Config: config,
Config: claudeConfig,
}, nil
}
@@ -506,21 +520,23 @@ func (m *Manager) ListSessions() []Info {
infos := make([]Info, 0, len(dbSessions))
for _, dbSession := range dbSessions {
info := Info{
ID: dbSession.ID,
RunID: dbSession.RunID,
ClaudeSessionID: dbSession.ClaudeSessionID,
ParentSessionID: dbSession.ParentSessionID,
Status: Status(dbSession.Status),
StartTime: dbSession.CreatedAt,
LastActivityAt: dbSession.LastActivityAt,
Error: dbSession.ErrorMessage,
Query: dbSession.Query,
Summary: dbSession.Summary,
Title: dbSession.Title,
Model: dbSession.Model,
WorkingDir: dbSession.WorkingDir,
AutoAcceptEdits: dbSession.AutoAcceptEdits,
Archived: dbSession.Archived,
ID: dbSession.ID,
RunID: dbSession.RunID,
ClaudeSessionID: dbSession.ClaudeSessionID,
ParentSessionID: dbSession.ParentSessionID,
Status: Status(dbSession.Status),
StartTime: dbSession.CreatedAt,
LastActivityAt: dbSession.LastActivityAt,
Error: dbSession.ErrorMessage,
Query: dbSession.Query,
Summary: dbSession.Summary,
Title: dbSession.Title,
Model: dbSession.Model,
WorkingDir: dbSession.WorkingDir,
AutoAcceptEdits: dbSession.AutoAcceptEdits,
Archived: dbSession.Archived,
DangerouslySkipPermissions: dbSession.DangerouslySkipPermissions,
DangerouslySkipPermissionsExpiresAt: dbSession.DangerouslySkipPermissionsExpiresAt,
}
// Set end time if completed
@@ -1108,6 +1124,16 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
dbSession.Summary = CalculateSummary(req.Query)
// Inherit auto-accept setting from parent
dbSession.AutoAcceptEdits = parentSession.AutoAcceptEdits
// Inherit dangerously skip permissions from parent
dbSession.DangerouslySkipPermissions = parentSession.DangerouslySkipPermissions
dbSession.DangerouslySkipPermissionsExpiresAt = parentSession.DangerouslySkipPermissionsExpiresAt
// Check if dangerously skip permissions has expired on the parent
if dbSession.DangerouslySkipPermissions && dbSession.DangerouslySkipPermissionsExpiresAt != nil && time.Now().After(*dbSession.DangerouslySkipPermissionsExpiresAt) {
dbSession.DangerouslySkipPermissions = false
dbSession.DangerouslySkipPermissionsExpiresAt = nil
}
// Inherit title from parent session
dbSession.Title = parentSession.Title
// Explicitly ensure inherited values are stored (in case NewSessionFromConfig didn't capture them)

View File

@@ -252,13 +252,15 @@ func TestLaunchSession_SetsMCPEnvironment(t *testing.T) {
mockStore.EXPECT().UpdateSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
// Launch session with MCP config
config := claudecode.SessionConfig{
Query: "test query",
MCPConfig: &claudecode.MCPConfig{
MCPServers: map[string]claudecode.MCPServer{
"test-server": {
Command: "test-cmd",
Args: []string{"arg1", "arg2"},
config := LaunchSessionConfig{
SessionConfig: claudecode.SessionConfig{
Query: "test query",
MCPConfig: &claudecode.MCPConfig{
MCPServers: map[string]claudecode.MCPServer{
"test-server": {
Command: "test-cmd",
Args: []string{"arg1", "arg2"},
},
},
},
},

View File

@@ -0,0 +1,117 @@
package session
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/humanlayer/humanlayer/hld/bus"
"github.com/humanlayer/humanlayer/hld/store"
)
// PermissionMonitor handles periodic cleanup of expired dangerous skip permissions
type PermissionMonitor struct {
store store.ConversationStore
eventBus bus.EventBus
interval time.Duration
}
// NewPermissionMonitor creates a new dangerous skip permissions monitor
func NewPermissionMonitor(store store.ConversationStore, eventBus bus.EventBus, interval time.Duration) *PermissionMonitor {
if interval <= 0 {
interval = 30 * time.Second
}
return &PermissionMonitor{
store: store,
eventBus: eventBus,
interval: interval,
}
}
// Start begins monitoring for expired dangerous skip permissions
func (pm *PermissionMonitor) Start(ctx context.Context) {
slog.Info("starting dangerous skip permissions expiry monitor", "interval", pm.interval)
ticker := time.NewTicker(pm.interval)
defer ticker.Stop()
// Do an initial check immediately
pm.checkAndDisableExpiredDangerouslySkipPermissions(ctx)
for {
select {
case <-ctx.Done():
slog.Info("dangerous skip permissions monitor shutting down")
return
case <-ticker.C:
pm.checkAndDisableExpiredDangerouslySkipPermissions(ctx)
}
}
}
func (pm *PermissionMonitor) checkAndDisableExpiredDangerouslySkipPermissions(ctx context.Context) {
// Guard against nil store (can happen during shutdown)
if pm.store == nil {
return
}
sessions, err := pm.store.GetExpiredDangerousPermissionsSessions(ctx)
if err != nil {
slog.Error("failed to query expired dangerous skip permissions sessions", "error", err)
return
}
if len(sessions) == 0 {
return
}
slog.Info("found sessions with expired dangerous skip permissions", "count", len(sessions))
for _, session := range sessions {
if err := pm.disableDangerouslySkipPermissions(ctx, session); err != nil {
slog.Error("failed to disable expired dangerous skip permissions",
"session_id", session.ID,
"expires_at", session.DangerouslySkipPermissionsExpiresAt,
"error", err)
// Continue with other sessions
}
}
}
func (pm *PermissionMonitor) disableDangerouslySkipPermissions(ctx context.Context, session *store.Session) error {
// Update the session in the database
dangerouslySkipPermissions := false
var nilTime *time.Time
update := store.SessionUpdate{
DangerouslySkipPermissions: &dangerouslySkipPermissions,
DangerouslySkipPermissionsExpiresAt: &nilTime,
}
if err := pm.store.UpdateSession(ctx, session.ID, update); err != nil {
return fmt.Errorf("failed to update session: %w", err)
}
// Publish event to notify clients
if pm.eventBus != nil {
event := bus.Event{
Type: bus.EventSessionSettingsChanged,
Timestamp: time.Now(),
Data: map[string]interface{}{
"session_id": session.ID,
"run_id": session.RunID,
"dangerously_skip_permissions": false,
"reason": string(bus.SessionSettingsChangeReasonExpired),
"expired_at": session.DangerouslySkipPermissionsExpiresAt,
},
}
pm.eventBus.Publish(event)
}
slog.Info("disabled expired dangerous skip permissions",
"session_id", session.ID,
"expired_at", session.DangerouslySkipPermissionsExpiresAt)
return nil
}

View File

@@ -0,0 +1,158 @@
package session
import (
"context"
"fmt"
"testing"
"time"
"github.com/humanlayer/humanlayer/hld/bus"
"github.com/humanlayer/humanlayer/hld/store"
"go.uber.org/mock/gomock"
)
func TestPermissionMonitor_DisableExpiredDangerouslySkipPermissions(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockConversationStore(ctrl)
eventBus := bus.NewEventBus()
monitor := NewPermissionMonitor(mockStore, eventBus, 30*time.Second)
// Set up test data
expiredTime := time.Now().Add(-1 * time.Hour)
sessions := []*store.Session{
{
ID: "session-1",
RunID: "run-1",
DangerouslySkipPermissions: true,
DangerouslySkipPermissionsExpiresAt: &expiredTime,
Status: store.SessionStatusRunning,
},
}
// Expect the query
mockStore.EXPECT().
GetExpiredDangerousPermissionsSessions(gomock.Any()).
Return(sessions, nil)
// Expect the update
mockStore.EXPECT().
UpdateSession(gomock.Any(), "session-1", gomock.Any()).
DoAndReturn(func(ctx context.Context, id string, update store.SessionUpdate) error {
if update.DangerouslySkipPermissions == nil || *update.DangerouslySkipPermissions != false {
t.Errorf("expected dangerous permissions to be disabled")
}
if update.DangerouslySkipPermissionsExpiresAt == nil {
t.Errorf("expected expires_at to be cleared")
}
return nil
})
// Subscribe to events to verify broadcast
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
Types: []bus.EventType{bus.EventSessionSettingsChanged},
})
// Run the check
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
// Verify event was published
select {
case event := <-subscriber.Channel:
if event.Type != bus.EventSessionSettingsChanged {
t.Errorf("expected settings changed event, got %v", event.Type)
}
data := event.Data
if data["session_id"] != "session-1" {
t.Errorf("expected session_id session-1, got %v", data["session_id"])
}
if data["dangerously_skip_permissions"] != false {
t.Errorf("expected dangerously_skip_permissions false, got %v", data["dangerously_skip_permissions"])
}
if data["reason"] != string(bus.SessionSettingsChangeReasonExpired) {
t.Errorf("expected reason %s, got %v", bus.SessionSettingsChangeReasonExpired, data["reason"])
}
case <-ctx.Done():
t.Fatal("timeout waiting for event")
}
}
func TestPermissionMonitor_ContinuesOnError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockConversationStore(ctrl)
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
sessions := []*store.Session{
{ID: "session-1", DangerouslySkipPermissions: true},
{ID: "session-2", DangerouslySkipPermissions: true},
}
mockStore.EXPECT().
GetExpiredDangerousPermissionsSessions(gomock.Any()).
Return(sessions, nil)
// First update fails
mockStore.EXPECT().
UpdateSession(gomock.Any(), "session-1", gomock.Any()).
Return(fmt.Errorf("database error"))
// Second update succeeds
mockStore.EXPECT().
UpdateSession(gomock.Any(), "session-2", gomock.Any()).
Return(nil)
// Should continue despite first error
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
}
func TestPermissionMonitor_HandlesEmptyResults(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockConversationStore(ctrl)
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
// Return empty results
mockStore.EXPECT().
GetExpiredDangerousPermissionsSessions(gomock.Any()).
Return([]*store.Session{}, nil)
// Should not call UpdateSession
// Test passes if no panic and no UpdateSession calls
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
}
func TestPermissionMonitor_HandlesQueryError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockConversationStore(ctrl)
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
// Query returns error
mockStore.EXPECT().
GetExpiredDangerousPermissionsSessions(gomock.Any()).
Return(nil, fmt.Errorf("database connection error"))
// Should handle error gracefully without panicking
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
}
func TestPermissionMonitor_DefaultInterval(t *testing.T) {
monitor := NewPermissionMonitor(nil, nil, 0)
if monitor.interval != 30*time.Second {
t.Errorf("expected default interval of 30s, got %v", monitor.interval)
}
monitor = NewPermissionMonitor(nil, nil, -1*time.Second)
if monitor.interval != 30*time.Second {
t.Errorf("expected default interval of 30s for negative input, got %v", monitor.interval)
}
}

View File

@@ -40,23 +40,33 @@ type Session struct {
// Info provides a JSON-safe view of the session
type Info struct {
ID string `json:"id"`
RunID string `json:"run_id"`
ClaudeSessionID string `json:"claude_session_id,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
Status Status `json:"status"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
LastActivityAt time.Time `json:"last_activity_at"`
Error string `json:"error,omitempty"`
Query string `json:"query"`
Summary string `json:"summary"`
Title string `json:"title"`
Model string `json:"model,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Result *claudecode.Result `json:"result,omitempty"`
AutoAcceptEdits bool `json:"auto_accept_edits"`
Archived bool `json:"archived"`
ID string `json:"id"`
RunID string `json:"run_id"`
ClaudeSessionID string `json:"claude_session_id,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
Status Status `json:"status"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
LastActivityAt time.Time `json:"last_activity_at"`
Error string `json:"error,omitempty"`
Query string `json:"query"`
Summary string `json:"summary"`
Title string `json:"title"`
Model string `json:"model,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Result *claudecode.Result `json:"result,omitempty"`
AutoAcceptEdits bool `json:"auto_accept_edits"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
DangerouslySkipPermissionsExpiresAt *time.Time `json:"dangerously_skip_permissions_expires_at,omitempty"`
Archived bool `json:"archived"`
}
// LaunchSessionConfig contains the configuration for launching a new session
type LaunchSessionConfig struct {
claudecode.SessionConfig
// Daemon-level settings that don't get passed to Claude Code
DangerouslySkipPermissions bool // Whether to auto-approve all tools
DangerouslySkipPermissionsTimeout *int64 // Optional timeout in milliseconds
}
// ContinueSessionConfig contains the configuration for continuing a session
@@ -76,7 +86,7 @@ type ContinueSessionConfig struct {
// SessionManager defines the interface for managing Claude Code sessions
type SessionManager interface {
// LaunchSession starts a new Claude Code session
LaunchSession(ctx context.Context, config claudecode.SessionConfig) (*Session, error)
LaunchSession(ctx context.Context, config LaunchSessionConfig) (*Session, error)
// ContinueSession resumes an existing completed session with a new query and optional config overrides
ContinueSession(ctx context.Context, req ContinueSessionConfig) (*Session, error)
@@ -112,21 +122,23 @@ type ReadToolResult struct {
// SessionToInfo converts a store.Session to Info for RPC responses
func SessionToInfo(s store.Session) Info {
info := Info{
ID: s.ID,
RunID: s.RunID,
ClaudeSessionID: s.ClaudeSessionID,
ParentSessionID: s.ParentSessionID,
Status: Status(s.Status),
StartTime: s.CreatedAt,
LastActivityAt: s.LastActivityAt,
Error: s.ErrorMessage,
Query: s.Query,
Summary: s.Summary,
Title: s.Title,
Model: s.Model,
WorkingDir: s.WorkingDir,
AutoAcceptEdits: s.AutoAcceptEdits,
Archived: s.Archived,
ID: s.ID,
RunID: s.RunID,
ClaudeSessionID: s.ClaudeSessionID,
ParentSessionID: s.ParentSessionID,
Status: Status(s.Status),
StartTime: s.CreatedAt,
LastActivityAt: s.LastActivityAt,
Error: s.ErrorMessage,
Query: s.Query,
Summary: s.Summary,
Title: s.Title,
Model: s.Model,
WorkingDir: s.WorkingDir,
AutoAcceptEdits: s.AutoAcceptEdits,
DangerouslySkipPermissions: s.DangerouslySkipPermissions,
DangerouslySkipPermissionsExpiresAt: s.DangerouslySkipPermissionsExpiresAt,
Archived: s.Archived,
}
if s.CompletedAt != nil {

View File

@@ -104,6 +104,8 @@ func (s *SQLiteStore) initSchema() error {
-- Session settings
auto_accept_edits BOOLEAN DEFAULT 0,
dangerously_skip_permissions BOOLEAN DEFAULT 0,
dangerously_skip_permissions_expires_at TIMESTAMP,
-- Archival
archived BOOLEAN DEFAULT FALSE
@@ -557,6 +559,61 @@ func (s *SQLiteStore) applyMigrations() error {
slog.Info("Migration 10 applied successfully")
}
// Migration 11: Add dangerously skip permissions with timeout support
if currentVersion < 11 {
slog.Info("Applying migration 11: Add dangerously skip permissions columns")
// Check if columns already exist
var skipPermissionsExists, expiryExists int
err = s.db.QueryRow(`
SELECT COUNT(*) FROM pragma_table_info('sessions')
WHERE name = 'dangerously_skip_permissions'
`).Scan(&skipPermissionsExists)
if err != nil {
return fmt.Errorf("failed to check dangerously_skip_permissions column: %w", err)
}
err = s.db.QueryRow(`
SELECT COUNT(*) FROM pragma_table_info('sessions')
WHERE name = 'dangerously_skip_permissions_expires_at'
`).Scan(&expiryExists)
if err != nil {
return fmt.Errorf("failed to check dangerously_skip_permissions_expires_at column: %w", err)
}
// Add columns if they don't exist
if skipPermissionsExists == 0 {
_, err = s.db.Exec(`
ALTER TABLE sessions
ADD COLUMN dangerously_skip_permissions BOOLEAN DEFAULT 0
`)
if err != nil {
return fmt.Errorf("failed to add dangerously_skip_permissions column: %w", err)
}
}
if expiryExists == 0 {
_, err = s.db.Exec(`
ALTER TABLE sessions
ADD COLUMN dangerously_skip_permissions_expires_at TIMESTAMP
`)
if err != nil {
return fmt.Errorf("failed to add dangerously_skip_permissions_expires_at column: %w", err)
}
}
// Record migration
_, err = s.db.Exec(`
INSERT INTO schema_version (version, description)
VALUES (11, 'Add dangerously skip permissions with timeout support')
`)
if err != nil {
return fmt.Errorf("failed to record migration 11: %w", err)
}
slog.Info("Migration 11 applied successfully")
}
return nil
}
@@ -572,8 +629,8 @@ func (s *SQLiteStore) CreateSession(ctx context.Context, session *Session) error
id, run_id, claude_session_id, parent_session_id,
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
permission_prompt_tool, allowed_tools, disallowed_tools,
status, created_at, last_activity_at, auto_accept_edits, archived
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
status, created_at, last_activity_at, auto_accept_edits, archived, dangerously_skip_permissions, dangerously_skip_permissions_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := s.db.ExecContext(ctx, query,
@@ -582,6 +639,7 @@ func (s *SQLiteStore) CreateSession(ctx context.Context, session *Session) error
session.SystemPrompt, session.AppendSystemPrompt, session.CustomInstructions,
session.PermissionPromptTool, session.AllowedTools, session.DisallowedTools,
session.Status, session.CreatedAt, session.LastActivityAt, session.AutoAcceptEdits, session.Archived,
session.DangerouslySkipPermissions, session.DangerouslySkipPermissionsExpiresAt,
)
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
@@ -647,6 +705,18 @@ func (s *SQLiteStore) UpdateSession(ctx context.Context, sessionID string, updat
setParts = append(setParts, "auto_accept_edits = ?")
args = append(args, *updates.AutoAcceptEdits)
}
if updates.DangerouslySkipPermissions != nil {
setParts = append(setParts, "dangerously_skip_permissions = ?")
args = append(args, *updates.DangerouslySkipPermissions)
}
if updates.DangerouslySkipPermissionsExpiresAt != nil {
setParts = append(setParts, "dangerously_skip_permissions_expires_at = ?")
if *updates.DangerouslySkipPermissionsExpiresAt != nil {
args = append(args, **updates.DangerouslySkipPermissionsExpiresAt)
} else {
args = append(args, nil)
}
}
if updates.Model != nil {
setParts = append(setParts, "model = ?")
args = append(args, *updates.Model)
@@ -657,7 +727,8 @@ func (s *SQLiteStore) UpdateSession(ctx context.Context, sessionID string, updat
}
if len(setParts) == 0 {
return fmt.Errorf("no fields to update")
// No fields to update is OK - this is a no-op
return nil
}
query += " " + strings.Join(setParts, ", ")
@@ -689,7 +760,8 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
permission_prompt_tool, allowed_tools, disallowed_tools,
status, created_at, last_activity_at, completed_at,
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
FROM sessions WHERE id = ?
`
@@ -701,6 +773,7 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
var totalTokens, durationMS, numTurns sql.NullInt64
var resultContent, errorMessage sql.NullString
var archived sql.NullBool
var dangerouslySkipPermissionsExpiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, query, sessionID).Scan(
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
@@ -709,7 +782,7 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
&permissionPromptTool, &allowedTools, &disallowedTools,
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
&archived,
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("session not found: %s", sessionID)
@@ -755,6 +828,11 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
// Handle archived field - default to false if NULL
session.Archived = archived.Valid && archived.Bool
// Handle dangerously skip permissions expires at
if dangerouslySkipPermissionsExpiresAt.Valid {
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
}
return &session, nil
}
@@ -765,7 +843,8 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
permission_prompt_tool, allowed_tools, disallowed_tools,
status, created_at, last_activity_at, completed_at,
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
FROM sessions
WHERE run_id = ?
`
@@ -778,6 +857,7 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
var totalTokens, durationMS, numTurns sql.NullInt64
var resultContent, errorMessage sql.NullString
var archived sql.NullBool
var dangerouslySkipPermissionsExpiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, query, runID).Scan(
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
@@ -786,7 +866,7 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
&permissionPromptTool, &allowedTools, &disallowedTools,
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
&archived,
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
)
if err == sql.ErrNoRows {
return nil, nil // No session found
@@ -832,6 +912,11 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
// Handle archived field - default to false if NULL
session.Archived = archived.Valid && archived.Bool
// Handle dangerously skip permissions expires at
if dangerouslySkipPermissionsExpiresAt.Valid {
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
}
return &session, nil
}
@@ -842,7 +927,8 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
permission_prompt_tool, allowed_tools, disallowed_tools,
status, created_at, last_activity_at, completed_at,
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
FROM sessions
ORDER BY last_activity_at DESC
`
@@ -863,6 +949,7 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
var totalTokens, durationMS, numTurns sql.NullInt64
var resultContent, errorMessage sql.NullString
var archived sql.NullBool
var dangerouslySkipPermissionsExpiresAt sql.NullTime
err := rows.Scan(
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
@@ -871,7 +958,7 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
&permissionPromptTool, &allowedTools, &disallowedTools,
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
&archived,
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan session: %w", err)
@@ -916,12 +1003,120 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
// Handle archived field - default to false if NULL
session.Archived = archived.Valid && archived.Bool
// Handle dangerously skip permissions expires at
if dangerouslySkipPermissionsExpiresAt.Valid {
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
}
sessions = append(sessions, &session)
}
return sessions, nil
}
// GetExpiredDangerousPermissionsSessions returns sessions where dangerous permissions have expired
func (s *SQLiteStore) GetExpiredDangerousPermissionsSessions(ctx context.Context) ([]*Session, error) {
now := time.Now()
query := `
SELECT id, run_id, claude_session_id, parent_session_id,
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
permission_prompt_tool, allowed_tools, disallowed_tools,
status, created_at, last_activity_at, completed_at,
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
FROM sessions
WHERE dangerously_skip_permissions = 1
AND dangerously_skip_permissions_expires_at IS NOT NULL
AND dangerously_skip_permissions_expires_at < ?
AND status IN ('running', 'waiting_input', 'starting')
ORDER BY dangerously_skip_permissions_expires_at ASC
`
rows, err := s.db.QueryContext(ctx, query, now)
if err != nil {
return nil, fmt.Errorf("failed to query expired dangerous skip permissions sessions: %w", err)
}
defer func() { _ = rows.Close() }()
var sessions []*Session
for rows.Next() {
var session Session
var claudeSessionID, parentSessionID, summary, title, model, workingDir, systemPrompt, appendSystemPrompt, customInstructions sql.NullString
var permissionPromptTool, allowedTools, disallowedTools sql.NullString
var completedAt sql.NullTime
var costUSD sql.NullFloat64
var totalTokens, durationMS, numTurns sql.NullInt64
var resultContent, errorMessage sql.NullString
var archived sql.NullBool
var dangerouslySkipPermissionsExpiresAt sql.NullTime
err := rows.Scan(
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
&session.Query, &summary, &title, &model, &workingDir, &session.MaxTurns,
&systemPrompt, &appendSystemPrompt, &customInstructions,
&permissionPromptTool, &allowedTools, &disallowedTools,
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan session: %w", err)
}
// Handle nullable fields
session.ClaudeSessionID = claudeSessionID.String
session.ParentSessionID = parentSessionID.String
session.Summary = summary.String
session.Title = title.String
session.Model = model.String
session.WorkingDir = workingDir.String
session.SystemPrompt = systemPrompt.String
session.AppendSystemPrompt = appendSystemPrompt.String
session.CustomInstructions = customInstructions.String
session.PermissionPromptTool = permissionPromptTool.String
session.AllowedTools = allowedTools.String
session.DisallowedTools = disallowedTools.String
session.ResultContent = resultContent.String
session.ErrorMessage = errorMessage.String
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
if costUSD.Valid {
session.CostUSD = &costUSD.Float64
}
if totalTokens.Valid {
tokens := int(totalTokens.Int64)
session.TotalTokens = &tokens
}
if durationMS.Valid {
duration := int(durationMS.Int64)
session.DurationMS = &duration
}
if numTurns.Valid {
turns := int(numTurns.Int64)
session.NumTurns = &turns
}
session.ResultContent = resultContent.String
session.ErrorMessage = errorMessage.String
// Handle archived field - default to false if NULL
session.Archived = archived.Valid && archived.Bool
// Handle dangerously skip permissions expires at
if dangerouslySkipPermissionsExpiresAt.Valid {
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
}
sessions = append(sessions, &session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
return sessions, nil
}
// GetRecentWorkingDirs retrieves recently used working directories
func (s *SQLiteStore) GetRecentWorkingDirs(ctx context.Context, limit int) ([]RecentPath, error) {
if limit <= 0 {

View File

@@ -16,6 +16,8 @@ type ConversationStore interface {
GetSession(ctx context.Context, sessionID string) (*Session, error)
GetSessionByRunID(ctx context.Context, runID string) (*Session, error)
ListSessions(ctx context.Context) ([]*Session, error)
// GetExpiredDangerousPermissionsSessions returns sessions where dangerous permissions have expired
GetExpiredDangerousPermissionsSessions(ctx context.Context) ([]*Session, error)
// Conversation operations
AddConversationEvent(ctx context.Context, event *ConversationEvent) error
@@ -57,53 +59,57 @@ type ConversationStore interface {
// Session represents a Claude Code session
type Session struct {
ID string
RunID string
ClaudeSessionID string
ParentSessionID string
Query string
Summary string
Title string // New field for user-editable title
Model string
WorkingDir string
MaxTurns int
SystemPrompt string
AppendSystemPrompt string // NEW: Append to system prompt
CustomInstructions string
PermissionPromptTool string // NEW: MCP tool for permission prompts
AllowedTools string // NEW: JSON array of allowed tools
DisallowedTools string // NEW: JSON array of disallowed tools
Status string
CreatedAt time.Time
LastActivityAt time.Time
CompletedAt *time.Time
CostUSD *float64
TotalTokens *int
DurationMS *int
NumTurns *int
ResultContent string
ErrorMessage string
AutoAcceptEdits bool `db:"auto_accept_edits"`
Archived bool // New field for session archiving
ID string
RunID string
ClaudeSessionID string
ParentSessionID string
Query string
Summary string
Title string // New field for user-editable title
Model string
WorkingDir string
MaxTurns int
SystemPrompt string
AppendSystemPrompt string // NEW: Append to system prompt
CustomInstructions string
PermissionPromptTool string // NEW: MCP tool for permission prompts
AllowedTools string // NEW: JSON array of allowed tools
DisallowedTools string // NEW: JSON array of disallowed tools
Status string
CreatedAt time.Time
LastActivityAt time.Time
CompletedAt *time.Time
CostUSD *float64
TotalTokens *int
DurationMS *int
NumTurns *int
ResultContent string
ErrorMessage string
AutoAcceptEdits bool `db:"auto_accept_edits"`
DangerouslySkipPermissions bool `db:"dangerously_skip_permissions"`
DangerouslySkipPermissionsExpiresAt *time.Time `db:"dangerously_skip_permissions_expires_at"`
Archived bool // New field for session archiving
}
// SessionUpdate contains fields that can be updated
type SessionUpdate struct {
ClaudeSessionID *string
Summary *string
Title *string // New field for updating title
Status *string
LastActivityAt *time.Time
CompletedAt *time.Time
CostUSD *float64
TotalTokens *int
DurationMS *int
NumTurns *int
ResultContent *string
ErrorMessage *string
AutoAcceptEdits *bool `db:"auto_accept_edits"`
Model *string
Archived *bool // New field for updating archived status
ClaudeSessionID *string
Summary *string
Title *string // New field for updating title
Status *string
LastActivityAt *time.Time
CompletedAt *time.Time
CostUSD *float64
TotalTokens *int
DurationMS *int
NumTurns *int
ResultContent *string
ErrorMessage *string
AutoAcceptEdits *bool `db:"auto_accept_edits"`
DangerouslySkipPermissions *bool `db:"dangerously_skip_permissions"`
DangerouslySkipPermissionsExpiresAt **time.Time `db:"dangerously_skip_permissions_expires_at"`
Model *string
Archived *bool // New field for updating archived status
}
// ConversationEvent represents a single event in a conversation

View File

@@ -11,6 +11,8 @@ interface LaunchOptions {
daemonSocket?: string
configFile?: string
approvals?: boolean
dangerouslySkipPermissions?: boolean
dangerouslySkipPermissionsTimeout?: string
}
export const launchCommand = async (query: string, options: LaunchOptions = {}) => {
@@ -30,6 +32,13 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
console.log('Working directory:', options.workingDir || process.cwd())
console.log('Approvals enabled:', options.approvals !== false)
if (options.dangerouslySkipPermissions) {
console.log('⚠️ Dangerously skip permissions enabled - ALL tools will be auto-approved')
if (options.dangerouslySkipPermissionsTimeout) {
console.log(` Auto-disabling after ${options.dangerouslySkipPermissionsTimeout} minutes`)
}
}
// Connect to daemon
const client = await connectWithRetry(socketPath, 3, 1000)
@@ -55,6 +64,10 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
max_turns: options.maxTurns,
mcp_config: mcpConfig,
permission_prompt_tool: mcpConfig ? 'mcp__approvals__request_permission' : undefined,
dangerously_skip_permissions: options.dangerouslySkipPermissions,
dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout
? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000
: undefined,
})
console.log('\nSession launched successfully!')

View File

@@ -104,6 +104,14 @@ program
.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')
.option(
'--dangerously-skip-permissions',
'Enable dangerous skip permissions mode (bypasses all approval requirements)',
)
.option(
'--dangerously-skip-permissions-timeout <minutes>',
'Dangerously skip permissions timeout in minutes',
)
.option('--daemon-socket <path>', 'Path to daemon socket')
.option('--config-file <path>', 'Path to config file')
.action(launchCommand)

View File

@@ -4,6 +4,7 @@
"": {
"dependencies": {
"@humanlayer/hld-sdk": "file:../hld/sdk/typescript",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -236,6 +237,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],

View File

@@ -17,6 +17,7 @@
},
"dependencies": {
"@humanlayer/hld-sdk": "file:../hld/sdk/typescript",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",

View File

@@ -278,6 +278,23 @@
animation: pulse-warning 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Pulsing animation for yolo mode */
@keyframes pulse-error {
0%,
100% {
opacity: 1;
color: var(--terminal-error);
}
50% {
opacity: 0.5;
color: var(--terminal-error);
}
}
.animate-pulse-error {
animation: pulse-error 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Custom spinner animations */
@keyframes spin-reverse {
from {

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'
import { describe, test, expect, beforeEach, mock } from 'bun:test'
import { useStore } from './AppStore'
import { SessionStatus } from '@/lib/daemon/types'
import type { ConversationEvent } from '@/lib/daemon/types'
@@ -15,15 +15,18 @@ mock.module('@/lib/daemon', () => ({
daemonClient: mockDaemonClient,
}))
// Mock logger to avoid console noise
mock.module('@/lib/logging', () => ({
logger: {
log: mock(() => {}),
debug: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
},
}))
describe('AppStore - Event Handling', () => {
let originalConsoleError: typeof console.error
let consoleErrorSpy: ReturnType<typeof spyOn>
beforeEach(() => {
// Spy on console.error to verify error handling
originalConsoleError = console.error
consoleErrorSpy = spyOn(console, 'error')
// Reset store
const store = useStore.getState()
store.initSessions([])
@@ -34,10 +37,6 @@ describe('AppStore - Event Handling', () => {
mockGetConversation.mockReset()
})
afterEach(() => {
console.error = originalConsoleError
})
describe('Session Status Changes', () => {
test('should update session status in all relevant places', () => {
const store = useStore.getState()
@@ -149,16 +148,10 @@ describe('AppStore - Event Handling', () => {
// Mock error
mockGetConversation.mockRejectedValueOnce(new Error('Network error'))
// Should not throw
await store.refreshActiveSessionConversation('test-session-1')
// Should not throw - the key behavior we're testing
await expect(store.refreshActiveSessionConversation('test-session-1')).resolves.toBeUndefined()
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to refresh active session conversation:',
expect.any(Error),
)
// Conversation should remain unchanged
// Conversation should remain unchanged - critical behavior
const finalState = useStore.getState()
expect(finalState.activeSessionDetail?.conversation).toEqual(originalConversation)
})

View File

@@ -0,0 +1,391 @@
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import { useStore } from './AppStore'
import { createMockSession } from '@/test-utils'
import { ViewMode } from '@/lib/daemon/types'
// Create mock functions with proper typing
const mockGetSessionLeaves = mock(() => Promise.resolve({ sessions: [] as any[] }))
const mockUpdateSessionSettings = mock(() => Promise.resolve({ success: true }))
// Mock the daemon client module
mock.module('@/lib/daemon', () => ({
daemonClient: {
getSessionLeaves: mockGetSessionLeaves,
updateSessionSettings: mockUpdateSessionSettings,
},
}))
// Mock logger to avoid console noise
mock.module('@/lib/logging', () => ({
logger: {
log: mock(() => {}),
debug: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
},
}))
describe('AppStore - State Synchronization', () => {
beforeEach(() => {
// Reset store to initial state
useStore.setState({
sessions: [],
focusedSession: null,
viewMode: ViewMode.Normal,
selectedSessions: new Set(),
pendingUpdates: new Map(),
isRefreshing: false,
activeSessionDetail: null,
})
// Clear all mocks
mockGetSessionLeaves.mockClear()
mockUpdateSessionSettings.mockClear()
})
afterEach(() => {
// Clean up any timers
})
describe('optimistic updates', () => {
test('should apply updates optimistically and track as pending', async () => {
const session = createMockSession({ id: 'test-1' })
useStore.setState({ sessions: [session] })
// Mock successful API call
mockUpdateSessionSettings.mockResolvedValueOnce({ success: true })
// Apply optimistic update
const promise = useStore.getState().updateSessionOptimistic('test-1', {
autoAcceptEdits: true,
dangerouslySkipPermissions: true,
})
// Check immediate state update
const stateAfterOptimistic = useStore.getState()
expect(stateAfterOptimistic.sessions[0].autoAcceptEdits).toBe(true)
expect(stateAfterOptimistic.sessions[0].dangerouslySkipPermissions).toBe(true)
expect(stateAfterOptimistic.pendingUpdates.has('test-1')).toBe(true)
// Wait for API call to complete
await promise
// Check pending update is cleared after success
const stateAfterSuccess = useStore.getState()
expect(stateAfterSuccess.sessions[0].autoAcceptEdits).toBe(true)
expect(stateAfterSuccess.sessions[0].dangerouslySkipPermissions).toBe(true)
expect(stateAfterSuccess.pendingUpdates.has('test-1')).toBe(false)
})
test('should revert optimistic updates on API failure', async () => {
const session = createMockSession({
id: 'test-1',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
})
useStore.setState({ sessions: [session] })
// Mock API failure
mockUpdateSessionSettings.mockRejectedValueOnce(new Error('Network error'))
// Apply optimistic update
try {
await useStore.getState().updateSessionOptimistic('test-1', {
autoAcceptEdits: true,
dangerouslySkipPermissions: true,
})
} catch {
// Expected to throw
}
// Check state is reverted after failure
const stateAfterFailure = useStore.getState()
expect(stateAfterFailure.sessions[0].autoAcceptEdits).toBe(false)
expect(stateAfterFailure.sessions[0].dangerouslySkipPermissions).toBe(false)
expect(stateAfterFailure.pendingUpdates.has('test-1')).toBe(false)
})
test('should transform field names correctly for API calls', async () => {
const session = createMockSession({ id: 'test-1' })
useStore.setState({ sessions: [session] })
mockUpdateSessionSettings.mockResolvedValueOnce({ success: true })
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
await useStore.getState().updateSessionOptimistic('test-1', {
autoAcceptEdits: true,
dangerouslySkipPermissions: true,
dangerouslySkipPermissionsExpiresAt: expiresAt,
})
// Check API was called with correct field names
expect(mockUpdateSessionSettings).toHaveBeenCalledWith('test-1', {
auto_accept_edits: true,
dangerously_skip_permissions: true,
dangerously_skip_permissions_timeout_ms: expect.any(Number),
})
})
})
describe('refreshSessions - server as source of truth', () => {
test('should use server data as source of truth', async () => {
// Set up initial local state with modifications
const localSession = createMockSession({
id: 'test-1',
autoAcceptEdits: true,
dangerouslySkipPermissions: true,
})
useStore.setState({ sessions: [localSession] })
// Mock server response with different values
const serverSession = createMockSession({
id: 'test-1',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [serverSession] as any,
})
// Refresh sessions
await useStore.getState().refreshSessions()
// Should use server values, not local
const state = useStore.getState()
expect(state.sessions[0].autoAcceptEdits).toBe(false)
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
})
test('should preserve recent pending updates during refresh', async () => {
const session = createMockSession({ id: 'test-1' })
useStore.setState({ sessions: [session] })
// Add a pending update (simulating an in-flight update)
const pendingUpdate = {
updates: { autoAcceptEdits: true },
timestamp: Date.now() - 500, // 500ms ago - still recent
}
useStore.setState({
pendingUpdates: new Map([['test-1', pendingUpdate]]),
})
// Mock server response
const serverSession = createMockSession({
id: 'test-1',
autoAcceptEdits: false, // Server doesn't have the update yet
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [serverSession] as any,
})
// Refresh sessions
await useStore.getState().refreshSessions()
// Should preserve the pending update
const state = useStore.getState()
expect(state.sessions[0].autoAcceptEdits).toBe(true)
expect(state.pendingUpdates.has('test-1')).toBe(true)
})
test('should discard old pending updates during refresh', async () => {
const session = createMockSession({ id: 'test-1' })
useStore.setState({ sessions: [session] })
// Add an old pending update
const oldPendingUpdate = {
updates: { autoAcceptEdits: true },
timestamp: Date.now() - 3000, // 3 seconds ago - too old
}
useStore.setState({
pendingUpdates: new Map([['test-1', oldPendingUpdate]]),
})
// Mock server response
const serverSession = createMockSession({
id: 'test-1',
autoAcceptEdits: false,
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [serverSession] as any,
})
// Refresh sessions
await useStore.getState().refreshSessions()
// Should NOT preserve the old update
const state = useStore.getState()
expect(state.sessions[0].autoAcceptEdits).toBe(false)
expect(state.pendingUpdates.has('test-1')).toBe(false)
})
test('should prevent concurrent refreshes', async () => {
mockGetSessionLeaves.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ sessions: [] }), 100)),
)
// Start first refresh
const refresh1 = useStore.getState().refreshSessions()
// Try to start second refresh immediately
const refresh2 = useStore.getState().refreshSessions()
await Promise.all([refresh1, refresh2])
// Should only call API once
expect(mockGetSessionLeaves).toHaveBeenCalledTimes(1)
})
test('should set isRefreshing flag correctly', async () => {
mockGetSessionLeaves.mockResolvedValueOnce({ sessions: [] })
expect(useStore.getState().isRefreshing).toBe(false)
const refreshPromise = useStore.getState().refreshSessions()
// Should be refreshing immediately
expect(useStore.getState().isRefreshing).toBe(true)
await refreshPromise
// Should clear flag after completion
expect(useStore.getState().isRefreshing).toBe(false)
})
test('should clear isRefreshing flag on error', async () => {
mockGetSessionLeaves.mockRejectedValueOnce(new Error('Network error'))
const refreshPromise = useStore.getState().refreshSessions()
expect(useStore.getState().isRefreshing).toBe(true)
await refreshPromise
expect(useStore.getState().isRefreshing).toBe(false)
})
})
describe('state validation', () => {
test('should clean up expired dangerous skip permissions during refresh', async () => {
// Mock Date.now() for this test
const originalDateNow = Date.now
const now = new Date('2024-01-01T12:00:00Z').getTime()
Date.now = () => now
// Create session with expired dangerous skip permissions
const expiredSession = createMockSession({
id: 'test-1',
dangerouslySkipPermissions: true,
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T11:00:00Z'), // 1 hour ago
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [expiredSession] as any,
})
await useStore.getState().refreshSessions()
const state = useStore.getState()
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeUndefined()
// Restore original Date.now
Date.now = originalDateNow
})
test('should preserve valid dangerous skip permissions during refresh', async () => {
// Mock Date.now() for this test
const originalDateNow = Date.now
const now = new Date('2024-01-01T12:00:00Z').getTime()
Date.now = () => now
// Create session with valid dangerous skip permissions
const validSession = createMockSession({
id: 'test-1',
dangerouslySkipPermissions: true,
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T13:00:00Z'), // 1 hour future
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [validSession] as any,
})
await useStore.getState().refreshSessions()
const state = useStore.getState()
expect(state.sessions[0].dangerouslySkipPermissions).toBe(true)
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeDefined()
// Restore original Date.now
Date.now = originalDateNow
})
})
describe('race condition scenarios', () => {
test('should handle refresh during pending update correctly', async () => {
const session = createMockSession({ id: 'test-1' })
useStore.setState({ sessions: [session] })
// Start optimistic update (don't await)
mockUpdateSessionSettings.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ success: true }), 100)),
)
const updatePromise = useStore.getState().updateSessionOptimistic('test-1', {
autoAcceptEdits: true,
})
// Immediately trigger refresh
const serverSession = createMockSession({
id: 'test-1',
autoAcceptEdits: false, // Server doesn't know about update yet
})
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [serverSession] as any,
})
await useStore.getState().refreshSessions()
// Should preserve the pending update
expect(useStore.getState().sessions[0].autoAcceptEdits).toBe(true)
// Wait for update to complete
await updatePromise
// Should still have the update
expect(useStore.getState().sessions[0].autoAcceptEdits).toBe(true)
})
test('should handle dangerous skip permissions expiry during refresh', async () => {
// Mock Date constructor and Date.now for this test
const originalDateNow = Date.now
// Set initial time
const startTime = new Date('2024-01-01T12:00:00Z').getTime()
Date.now = () => startTime
const session = createMockSession({
id: 'test-1',
dangerouslySkipPermissions: true,
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T12:00:05Z'), // Expires in 5 seconds
})
useStore.setState({ sessions: [session] })
// Advance time so dangerous skip permissions expires
Date.now = () => new Date('2024-01-01T12:00:10Z').getTime()
// Server still returns old data
mockGetSessionLeaves.mockResolvedValueOnce({
sessions: [session] as any, // Still has dangerous skip permissions enabled
})
await useStore.getState().refreshSessions()
// Should clean up expired state even though server returned it as enabled
const state = useStore.getState()
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeUndefined()
// Restore original Date functions
Date.now = originalDateNow
})
})
})

View File

@@ -4,12 +4,21 @@ import { create } from 'zustand'
import { daemonClient } from '@/lib/daemon'
import { logger } from '@/lib/logging'
// Track pending updates for optimistic UI
interface PendingUpdate {
updates: Partial<Session>
timestamp: number
retryCount?: number
}
interface StoreState {
/* Sessions */
sessions: Session[]
focusedSession: Session | null
viewMode: ViewMode
selectedSessions: Set<string> // For bulk selection
pendingUpdates: Map<string, PendingUpdate> // Track in-flight updates
isRefreshing: boolean // Prevent concurrent refresh/updates
activeSessionDetail: {
session: Session
conversation: any[] // ConversationEvent[] from useConversation
@@ -19,6 +28,7 @@ interface StoreState {
initSessions: (sessions: Session[]) => void
updateSession: (sessionId: string, updates: Partial<Session>) => void
updateSessionOptimistic: (sessionId: string, updates: Partial<Session>) => Promise<void>
updateSessionStatus: (sessionId: string, status: SessionStatus) => void
refreshSessions: () => Promise<void>
setFocusedSession: (session: Session | null) => void
@@ -70,6 +80,8 @@ export const useStore = create<StoreState>((set, get) => ({
focusedSession: null,
viewMode: ViewMode.Normal,
selectedSessions: new Set<string>(),
pendingUpdates: new Map<string, PendingUpdate>(),
isRefreshing: false,
activeSessionDetail: null,
initSessions: (sessions: Session[]) => set({ sessions }),
updateSession: (sessionId: string, updates: Partial<Session>) =>
@@ -90,6 +102,97 @@ export const useStore = create<StoreState>((set, get) => ({
}
: state.activeSessionDetail,
})),
updateSessionOptimistic: async (sessionId: string, updates: Partial<Session>) => {
const timestamp = Date.now()
// Capture original session state before applying optimistic update
const originalSession = get().sessions.find(s => s.id === sessionId)
if (!originalSession) {
logger.error(`Session ${sessionId} not found`)
throw new Error(`Session ${sessionId} not found`)
}
// Apply optimistic update immediately
set(state => ({
sessions: state.sessions.map(session =>
session.id === sessionId ? { ...session, ...updates } : session,
),
pendingUpdates: new Map(state.pendingUpdates).set(sessionId, {
updates,
timestamp,
}),
focusedSession:
state.focusedSession?.id === sessionId
? { ...state.focusedSession, ...updates }
: state.focusedSession,
activeSessionDetail:
state.activeSessionDetail?.session.id === sessionId
? {
...state.activeSessionDetail,
session: { ...state.activeSessionDetail.session, ...updates },
}
: state.activeSessionDetail,
}))
try {
// Send to server - map to the fields the API expects
const apiUpdates: any = {}
if (updates.autoAcceptEdits !== undefined) {
apiUpdates.auto_accept_edits = updates.autoAcceptEdits
}
if (updates.dangerouslySkipPermissions !== undefined) {
apiUpdates.dangerously_skip_permissions = updates.dangerouslySkipPermissions
}
if (updates.dangerouslySkipPermissionsExpiresAt !== undefined) {
// Convert Date to milliseconds for API
const expiresAt = updates.dangerouslySkipPermissionsExpiresAt
if (expiresAt) {
const now = Date.now()
const expiry = new Date(expiresAt).getTime()
apiUpdates.dangerously_skip_permissions_timeout_ms = expiry - now
} else {
apiUpdates.dangerously_skip_permissions_timeout_ms = undefined
}
}
await daemonClient.updateSessionSettings(sessionId, apiUpdates)
// Remove from pending on success
set(state => {
const pending = new Map(state.pendingUpdates)
pending.delete(sessionId)
return { pendingUpdates: pending }
})
} catch (error) {
logger.error('Failed to update session settings:', error)
// Revert optimistic update on failure
set(state => ({
sessions: state.sessions.map(session => {
if (session.id === sessionId) {
// Revert to original session state
return originalSession
}
return session
}),
pendingUpdates: (() => {
const pending = new Map(state.pendingUpdates)
pending.delete(sessionId)
return pending
})(),
focusedSession: state.focusedSession?.id === sessionId ? originalSession : state.focusedSession,
activeSessionDetail:
state.activeSessionDetail?.session.id === sessionId
? {
...state.activeSessionDetail,
session: originalSession,
}
: state.activeSessionDetail,
}))
throw error // Re-throw for caller to handle
}
},
updateSessionStatus: (sessionId: string, status: string) =>
set(state => ({
sessions: state.sessions.map(session =>
@@ -109,15 +212,56 @@ export const useStore = create<StoreState>((set, get) => ({
: state.activeSessionDetail,
})),
refreshSessions: async () => {
// Prevent concurrent refreshes
const state = get()
if (state.isRefreshing) {
logger.debug('Refresh already in progress, skipping')
return
}
set({ isRefreshing: true })
try {
const { viewMode } = get()
const { viewMode, pendingUpdates } = get()
const response = await daemonClient.getSessionLeaves({
include_archived: viewMode === ViewMode.Archived,
archived_only: viewMode === ViewMode.Archived,
})
set({ sessions: response.sessions })
// Server is source of truth, but preserve unresolved pending updates
const updatedSessions = response.sessions.map(serverSession => {
const pending = pendingUpdates.get(serverSession.id)
// Only preserve pending updates that are recent (< 2 seconds old)
if (pending && pending.timestamp > Date.now() - 2000) {
logger.debug(`Preserving pending updates for session ${serverSession.id}`)
return validateSessionState({
...serverSession,
...pending.updates,
})
}
// Otherwise, use server data as-is (with validation)
return validateSessionState(serverSession)
})
// Clean up old pending updates
const cleanedPending = new Map<string, PendingUpdate>()
pendingUpdates.forEach((update, sessionId) => {
// Keep updates less than 2 seconds old
if (update.timestamp > Date.now() - 2000) {
cleanedPending.set(sessionId, update)
}
})
set({
sessions: updatedSessions,
pendingUpdates: cleanedPending,
isRefreshing: false,
})
} catch (error) {
logger.error('Failed to refresh sessions:', error)
set({ isRefreshing: false })
}
},
refreshActiveSessionConversation: async (sessionId: string) => {
@@ -619,3 +763,43 @@ export const useStore = create<StoreState>((set, get) => ({
isHotkeyPanelOpen: false,
setHotkeyPanelOpen: (open: boolean) => set({ isHotkeyPanelOpen: open }),
}))
// Helper function to validate and clean up session state
export const validateSessionState = (session: Session): Session => {
// Check if dangerous skip permissions should be disabled due to expiry
if (session.dangerouslySkipPermissions && session.dangerouslySkipPermissionsExpiresAt) {
const now = Date.now()
const expiry = new Date(session.dangerouslySkipPermissionsExpiresAt).getTime()
if (expiry <= now) {
// Expired - disable dangerous skip permissions
logger.debug(`Session ${session.id} dangerous skip permissions expired, cleaning up`)
return {
...session,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
}
}
}
return session
}
// Run validation periodically to clean up expired states
if (typeof window !== 'undefined') {
setInterval(() => {
const state = useStore.getState()
const validatedSessions = state.sessions.map(validateSessionState)
// Only update if something changed
const hasChanges = validatedSessions.some(
(validated, index) =>
validated.dangerouslySkipPermissions !== state.sessions[index].dangerouslySkipPermissions,
)
if (hasChanges) {
logger.debug('Cleaning up expired session states')
useStore.setState({ sessions: validatedSessions })
}
}, 5000) // Check every 5 seconds
}

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react'
import { useStore } from '@/AppStore'
import { notificationService } from '@/services/NotificationService'
import { useLocation } from 'react-router-dom'
import { logger } from '@/lib/logging'
/**
* Global monitor for dangerous skip permissions timers.
* Watches all sessions and automatically disables dangerous skip permissions when timers expire,
* regardless of which session is currently being viewed.
*/
export const DangerousSkipPermissionsMonitor = () => {
const { sessions, updateSessionOptimistic } = useStore()
const location = useLocation()
useEffect(() => {
// Track intervals for each session
const intervals = new Map<string, ReturnType<typeof setInterval>>()
// Set up monitoring for each session with active dangerous skip permissions
sessions.forEach(session => {
if (session.dangerouslySkipPermissions && session.dangerouslySkipPermissionsExpiresAt) {
const checkExpiration = async () => {
const now = new Date().getTime()
const expiry = new Date(session.dangerouslySkipPermissionsExpiresAt!).getTime()
const remaining = expiry - now
if (remaining <= 0) {
try {
// Use optimistic update to disable dangerous skip permissions
await updateSessionOptimistic(session.id, {
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
})
// Only show notification if not currently viewing this session
const isViewingSession = location.pathname === `/session/${session.id}`
if (!isViewingSession) {
// Show notification using NotificationService
await notificationService.notify({
type: 'settings_changed',
title: 'Dangerous skip permissions expired',
body: `Session: ${session.title || session.summary || 'Untitled'}`,
metadata: {
sessionId: session.id,
action: 'dangerous_skip_permissions_expired',
},
duration: 5000,
priority: 'normal',
})
}
// Clear this interval
const interval = intervals.get(session.id)
if (interval) {
clearInterval(interval)
intervals.delete(session.id)
}
} catch (error) {
logger.error(
`Failed to disable expired dangerous skip permissions for session ${session.id}:`,
error,
)
}
}
}
// Check immediately
checkExpiration()
// Then check every second
const interval = setInterval(checkExpiration, 1000)
intervals.set(session.id, interval)
}
})
// Cleanup function
return () => {
intervals.forEach(interval => clearInterval(interval))
intervals.clear()
}
}, [sessions, updateSessionOptimistic, location.pathname])
// This component doesn't render anything
return null
}

View File

@@ -6,6 +6,7 @@ import {
daemonClient,
NewApprovalEventData,
SessionSettingsChangedEventData,
SessionSettingsChangeReason,
SessionStatus,
SessionStatusChangedEventData,
} from '@/lib/daemon'
@@ -29,6 +30,7 @@ import { DebugPanel } from '@/components/DebugPanel'
import { notifyLogLocation } from '@/lib/log-notification'
import '@/App.css'
import { logger } from '@/lib/logging'
import { DangerousSkipPermissionsMonitor } from '@/components/DangerousSkipPermissionsMonitor'
import { KeyboardShortcut } from '@/components/HotkeyPanel'
export function Layout() {
@@ -192,10 +194,58 @@ export function Layout() {
},
// CODEREVIEW: Why did this previously exist? Sundeep wants to talk about this do not merge.
onSessionSettingsChanged: async (data: SessionSettingsChangedEventData) => {
// Placeholder handler - to be implemented based on requirements
logger.log('useSessionSubscriptions.onSessionSettingsChanged', data)
const { session_id, auto_accept_edits } = data
updateSession(session_id, { autoAcceptEdits: auto_accept_edits })
// Check if this is an expiry event from the server
if (
data.reason === SessionSettingsChangeReason.EXPIRED &&
data.dangerously_skip_permissions === false
) {
logger.debug('Server disabled expired dangerous skip permissions', {
sessionId: data.session_id,
expiredAt: data.expired_at,
})
}
const updates: Record<string, any> = {}
if (data.auto_accept_edits !== undefined) {
updates.autoAcceptEdits = data.auto_accept_edits
}
if (data.dangerously_skip_permissions !== undefined) {
updates.dangerouslySkipPermissions = data.dangerously_skip_permissions
// Calculate expiry time if timeout provided
if (data.dangerously_skip_permissions && data.dangerously_skip_permissions_timeout_ms) {
const expiresAt = new Date(Date.now() + data.dangerously_skip_permissions_timeout_ms)
updates.dangerouslySkipPermissionsExpiresAt = expiresAt
} else if (!data.dangerously_skip_permissions) {
updates.dangerouslySkipPermissionsExpiresAt = undefined
}
}
updateSession(data.session_id, updates)
// Show notification
if (notificationService) {
const title = data.dangerously_skip_permissions
? 'Bypassing permissions enabled'
: data.auto_accept_edits
? 'Auto-accept edits enabled'
: 'Auto-accept disabled'
notificationService.notify({
type: 'settings_changed',
title,
body: data.dangerously_skip_permissions
? 'ALL tools will be automatically approved'
: data.auto_accept_edits
? 'Edit, Write, and MultiEdit tools will be automatically approved'
: 'All tools require manual approval',
metadata: { sessionId: data.session_id },
})
}
},
})
@@ -435,6 +485,9 @@ export function Layout() {
{/* Debug Panel */}
<DebugPanel open={isDebugPanelOpen} onOpenChange={setIsDebugPanelOpen} />
{/* Global Dangerous Skip Permissions Monitor */}
<DangerousSkipPermissionsMonitor />
</div>
)
}

View File

@@ -1,18 +1,110 @@
import { FC } from 'react'
import { FC, useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { ShieldOff } from 'lucide-react'
import { toast } from 'sonner'
import { useStore } from '@/AppStore'
import { logger } from '@/lib/logging'
interface AutoAcceptIndicatorProps {
enabled: boolean
interface SessionModeIndicatorProps {
sessionId: string
autoAcceptEdits: boolean
dangerouslySkipPermissions: boolean
dangerouslySkipPermissionsExpiresAt?: string
className?: string
}
export const AutoAcceptIndicator: FC<AutoAcceptIndicatorProps> = ({ enabled, className }) => {
if (!enabled) return null
export const SessionModeIndicator: FC<SessionModeIndicatorProps> = ({
sessionId,
autoAcceptEdits,
dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt,
className,
}) => {
const [timeRemaining, setTimeRemaining] = useState<string>('')
const { updateSessionOptimistic } = useStore()
useEffect(() => {
if (!dangerouslySkipPermissions || !dangerouslySkipPermissionsExpiresAt) return
const updateTimer = async () => {
const now = new Date().getTime()
const expiry = new Date(dangerouslySkipPermissionsExpiresAt).getTime()
const remaining = Math.max(0, expiry - now)
if (remaining === 0) {
// Timer expired - disable dangerous skip permissions
try {
await updateSessionOptimistic(sessionId, {
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
})
// Show notification
toast.info('Dangerous skip permissions expired', {
description: 'Manual approval required for all tools',
duration: 5000,
})
} catch (error) {
logger.error('Failed to disable expired dangerous skip permissions:', error)
}
return
}
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, '0')}`)
}
updateTimer()
const interval = setInterval(updateTimer, 1000)
return () => clearInterval(interval)
}, [
dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt,
sessionId,
updateSessionOptimistic,
])
// Show nothing if neither mode is active
if (!autoAcceptEdits && !dangerouslySkipPermissions) return null
// Dangerous skip permissions takes precedence in display
if (dangerouslySkipPermissions) {
return (
<div
className={cn(
'flex items-center justify-between gap-3 px-3 py-1.5',
'text-sm font-medium',
'bg-[var(--terminal-error)]/15',
'text-[var(--terminal-error)]',
'border border-[var(--terminal-error)]/40',
'rounded-md',
'animate-pulse-error',
className,
)}
>
<div className="flex items-center gap-2">
<ShieldOff className="h-4 w-4" strokeWidth={3} />
<span>BYPASSING PERMISSIONS</span>
{dangerouslySkipPermissionsExpiresAt && timeRemaining && (
<span className="font-mono text-sm">{timeRemaining}</span>
)}
</div>
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 text-xs font-mono font-medium border border-[var(--terminal-error)]/30 rounded">
Y
</kbd>
<span className="text-xs opacity-75">to disable</span>
</div>
</div>
)
}
// Auto-accept mode display
return (
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5',
'flex items-center justify-between gap-3 px-3 py-1.5',
'text-sm font-medium',
'bg-[var(--terminal-warning)]/15',
'text-[var(--terminal-warning)]',
@@ -22,8 +114,19 @@ export const AutoAcceptIndicator: FC<AutoAcceptIndicatorProps> = ({ enabled, cla
className,
)}
>
<span className="text-base leading-none"></span>
<span>auto-accept edits on (shift+tab to cycle)</span>
<div className="flex items-center gap-2">
<span className="text-base leading-none"></span>
<span>auto-accept edits on</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 text-xs font-mono font-medium border border-[var(--terminal-warning)]/30 rounded">
Shift+Tab
</kbd>
<span className="text-xs opacity-75">to disable</span>
</div>
</div>
)
}
// Export with backward compatibility
export const AutoAcceptIndicator = SessionModeIndicator

View File

@@ -0,0 +1,135 @@
import { FC, useState, useRef } from 'react'
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { AlertTriangle } from 'lucide-react'
interface DangerouslySkipPermissionsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (timeoutMinutes: number | null) => void
}
export const DangerouslySkipPermissionsDialog: FC<DangerouslySkipPermissionsDialogProps> = ({
open,
onOpenChange,
onConfirm,
}) => {
const [timeoutMinutes, setTimeoutMinutes] = useState<number | ''>(15)
const [useTimeout, setUseTimeout] = useState(true)
const timeoutInputRef = useRef<HTMLInputElement>(null)
// Reset to default when dialog opens
React.useEffect(() => {
if (open) {
setTimeoutMinutes(15)
setUseTimeout(true)
}
}, [open])
const handleConfirm = () => {
const minutes = useTimeout ? (timeoutMinutes === '' ? 15 : timeoutMinutes) : null
onConfirm(minutes)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[475px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--terminal-error)] text-base font-bold">
<AlertTriangle className="h-4 w-4" />
Bypass Permissions
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>
Bypassing permissions will <strong>automatically accept ALL tool calls</strong> without
your approval. This includes:
</p>
<ul className="list-disc list-inside space-y-1">
<li>File edits and writes</li>
<li>Running bash commands</li>
<li>Reading files</li>
<li>Spawning sub-tasks</li>
<li>All MCP tool calls</li>
</ul>
<p className="text-[var(--terminal-error)] font-semibold">Use with extreme caution!</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="pb-0 pt-6">
<div className="flex items-center justify-end space-x-2 min-h-[36px]">
<Checkbox
id="use-timeout"
checked={useTimeout}
onCheckedChange={(checked: boolean) => {
setUseTimeout(checked)
if (checked) {
setTimeout(() => {
if (timeoutInputRef.current) {
timeoutInputRef.current.focus()
const value = timeoutInputRef.current.value
timeoutInputRef.current.setSelectionRange(value.length, value.length)
}
}, 0)
}
}}
/>
<Label htmlFor="use-timeout">Auto-disable after</Label>
{useTimeout ? (
<>
<Input
ref={timeoutInputRef}
id="timeout"
type="number"
min="1"
max="60"
value={timeoutMinutes}
onChange={e => {
const value = e.target.value
if (value === '') {
setTimeoutMinutes('')
} else {
const parsed = parseInt(value)
if (!isNaN(parsed) && parsed >= 0) {
setTimeoutMinutes(parsed)
}
}
}}
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span>{timeoutMinutes === 1 ? 'minute' : 'minutes'}</span>
</>
) : (
<span>timeout</span>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={handleConfirm}
disabled={useTimeout && (timeoutMinutes === '' || timeoutMinutes === 0)}
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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -20,8 +20,9 @@ import { ToolResultModal } from './components/ToolResultModal'
import { TodoWidget } from './components/TodoWidget'
import { ResponseInput } from './components/ResponseInput'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AutoAcceptIndicator } from './AutoAcceptIndicator'
import { SessionModeIndicator } from './AutoAcceptIndicator'
import { ForkViewModal } from './components/ForkViewModal'
import { DangerouslySkipPermissionsDialog } from './DangerouslySkipPermissionsDialog'
// Import hooks
import { useSessionActions } from './hooks/useSessionActions'
@@ -187,6 +188,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
const [previewEventIndex, setPreviewEventIndex] = useState<number | null>(null)
const [pendingForkMessage, setPendingForkMessage] = useState<ConversationEvent | null>(null)
const [confirmingArchive, setConfirmingArchive] = useState(false)
const [dangerousSkipPermissionsDialogOpen, setDangerousSkipPermissionsDialogOpen] = useState(false)
// State for inline title editing
const [isEditingTitle, setIsEditingTitle] = useState(false)
@@ -227,9 +229,52 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
const responseInputRef = useRef<HTMLTextAreaElement>(null)
const confirmingArchiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Get session from store to access auto_accept_edits
// Get session from store to access auto_accept_edits and dangerouslySkipPermissions
// Always prioritize store values as they are the source of truth for runtime state
const sessionFromStore = useStore(state => state.sessions.find(s => s.id === session.id))
const autoAcceptEdits = sessionFromStore?.autoAcceptEdits ?? false
const updateSessionOptimistic = useStore(state => state.updateSessionOptimistic)
// Use store values if available, otherwise fall back to session prop
// Store values take precedence because they reflect real-time updates
const autoAcceptEdits =
sessionFromStore?.autoAcceptEdits !== undefined
? sessionFromStore.autoAcceptEdits
: (session.autoAcceptEdits ?? false)
const dangerouslySkipPermissions =
sessionFromStore?.dangerouslySkipPermissions !== undefined
? sessionFromStore.dangerouslySkipPermissions
: (session.dangerouslySkipPermissions ?? false)
const dangerouslySkipPermissionsExpiresAt =
sessionFromStore?.dangerouslySkipPermissionsExpiresAt !== undefined
? sessionFromStore.dangerouslySkipPermissionsExpiresAt?.toISOString()
: session.dangerouslySkipPermissionsExpiresAt?.toISOString()
// Debug logging
useEffect(() => {
logger.log('Session permissions state', {
sessionId: session.id,
dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt,
sessionFromStore: sessionFromStore
? {
id: sessionFromStore.id,
dangerouslySkipPermissions: sessionFromStore.dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt: sessionFromStore.dangerouslySkipPermissionsExpiresAt,
}
: 'not found',
sessionProp: {
dangerouslySkipPermissions: session.dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt: session.dangerouslySkipPermissionsExpiresAt,
},
})
}, [
session.id,
dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt,
sessionFromStore?.dangerouslySkipPermissions,
])
// Generate random verb that changes every 10-20 seconds
const [randomVerb, setRandomVerb] = useState(() => {
@@ -440,18 +485,13 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
useHotkeys(
'shift+tab',
async () => {
console.log('shift+tab setAutoAcceptEdits', autoAcceptEdits)
logger.log('shift+tab setAutoAcceptEdits', autoAcceptEdits)
try {
const newState = !autoAcceptEdits
const updatedSession = await daemonClient.updateSessionSettings(session.id, {
auto_accept_edits: newState,
})
if (updatedSession.success) {
useStore.getState().updateSession(session.id, { autoAcceptEdits: newState })
}
await updateSessionOptimistic(session.id, { autoAcceptEdits: newState })
} catch (error) {
logger.error('Failed to toggle auto-accept mode:', error)
toast.error('Failed to toggle auto-accept mode')
}
},
{
@@ -461,6 +501,53 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
[session.id, autoAcceptEdits], // Dependencies
)
// Add Option+Y handler for dangerously skip permissions mode
useHotkeys(
'alt+y',
async () => {
logger.log('Option+Y pressed', { dangerouslySkipPermissions, sessionId: session.id })
if (dangerouslySkipPermissions) {
// Disable dangerous skip permissions
try {
await updateSessionOptimistic(session.id, {
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
})
} catch (error) {
logger.error('Failed to disable dangerous skip permissions', { error })
toast.error('Failed to disable dangerous skip permissions')
}
} else {
// Show confirmation dialog
logger.log('Opening dangerous skip permissions dialog')
setDangerousSkipPermissionsDialogOpen(true)
}
},
{
scopes: [SessionDetailHotkeysScope],
preventDefault: true,
},
[session.id, dangerouslySkipPermissions],
)
// Handle dialog confirmation
const handleDangerousSkipPermissionsConfirm = async (timeoutMinutes: number | null) => {
try {
// Immediately update the store for instant UI feedback
const expiresAt = timeoutMinutes
? new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString()
: undefined
await updateSessionOptimistic(session.id, {
dangerouslySkipPermissions: true,
dangerouslySkipPermissionsExpiresAt: expiresAt ? new Date(expiresAt) : undefined,
})
} catch (error) {
logger.error('Failed to enable dangerous skip permissions', { error })
toast.error('Failed to enable dangerous skip permissions')
}
}
// Add hotkey to archive session ('e' key)
useHotkeys(
'e',
@@ -743,6 +830,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
/>
</div>
)}
{isCompactView && (
<div className="flex items-start justify-between">
<hgroup className="flex flex-col gap-0.5 flex-1">
@@ -922,7 +1010,14 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
isForkMode={actions.isForkMode}
onOpenForkView={() => setForkViewOpen(true)}
/>
<AutoAcceptIndicator enabled={autoAcceptEdits} className="mt-2" />
{/* Session mode indicator - shows either dangerous skip permissions or auto-accept */}
<SessionModeIndicator
sessionId={session.id}
autoAcceptEdits={autoAcceptEdits}
dangerouslySkipPermissions={dangerouslySkipPermissions}
dangerouslySkipPermissionsExpiresAt={dangerouslySkipPermissionsExpiresAt}
className="mt-2"
/>
</CardContent>
</Card>
@@ -937,6 +1032,13 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
}}
/>
)}
{/* Dangerously Skip Permissions Dialog */}
<DangerouslySkipPermissionsDialog
open={dangerousSkipPermissionsDialogOpen}
onOpenChange={setDangerousSkipPermissionsDialogOpen}
onConfirm={handleDangerousSkipPermissionsConfirm}
/>
</section>
)
}

View File

@@ -3,7 +3,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '.
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'
import { useEffect, useRef, useState } from 'react'
import { CircleOff, CheckSquare, Square, FileText, Pencil } from 'lucide-react'
import { CircleOff, CheckSquare, Square, FileText, Pencil, ShieldOff } from 'lucide-react'
import { getStatusTextClass } from '@/utils/component-utils'
import { formatTimestamp, formatAbsoluteTimestamp } from '@/utils/formatting'
import { highlightMatches } from '@/lib/fuzzy-search'
@@ -423,10 +423,21 @@ export default function SessionTable({
</div>
</TableCell>
<TableCell className={getStatusTextClass(session.status)}>
{session.status === SessionStatus.Running && session.autoAcceptEdits && (
<span className="align-text-top text-[var(--terminal-warning)] text-base leading-none animate-pulse-warning">
{'⏵⏵ '}
</span>
{session.status !== SessionStatus.Failed && (
<>
{session.dangerouslySkipPermissions ? (
<>
<ShieldOff
className="inline-block w-4 h-4 text-[var(--terminal-error)] animate-pulse-error align-text-bottom"
strokeWidth={3}
/>{' '}
</>
) : session.autoAcceptEdits ? (
<span className="align-text-top text-[var(--terminal-warning)] text-base leading-none animate-pulse-warning">
{'⏵⏵ '}
</span>
) : null}
</>
)}
{renderSessionStatus(session)}
</TableCell>

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -2,7 +2,6 @@ import {
CreateSessionResponseData,
HLDClient,
RecentPath as SDKRecentPath,
Session,
Approval,
ConversationEvent,
} from '@humanlayer/hld-sdk'
@@ -17,7 +16,9 @@ import type {
SubscriptionHandle,
SessionSnapshot,
HealthCheckResponse,
Session,
} from './types'
import { transformSDKSession } from './types'
export class HTTPDaemonClient implements IDaemonClient {
private client?: HLDClient
@@ -155,7 +156,7 @@ export class HTTPDaemonClient implements IDaemonClient {
async listSessions(): Promise<Session[]> {
await this.ensureConnected()
const response = await this.client!.listSessions({ leafOnly: true })
return response
return response.map(transformSDKSession)
}
async getSessionLeaves(request?: {
@@ -168,8 +169,18 @@ export class HTTPDaemonClient implements IDaemonClient {
leafOnly: true,
includeArchived: request?.include_archived,
})
logger.debug(
'getSessionLeaves raw response sample:',
response[0]
? {
id: response[0].id,
dangerouslySkipPermissions: response[0].dangerouslySkipPermissions,
dangerouslySkipPermissionsExpiresAt: response[0].dangerouslySkipPermissionsExpiresAt,
}
: 'no sessions',
)
return {
sessions: response,
sessions: response.map(transformSDKSession),
}
}
@@ -179,7 +190,7 @@ export class HTTPDaemonClient implements IDaemonClient {
// Transform to expected SessionState format
return {
session: session,
session: transformSDKSession(session),
pendingApprovals: [], // Will be populated if needed
}
}
@@ -204,13 +215,35 @@ export class HTTPDaemonClient implements IDaemonClient {
async updateSessionSettings(
sessionId: string,
settings: { auto_accept_edits?: boolean },
settings: {
auto_accept_edits?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout_ms?: number
},
): Promise<{ success: boolean }> {
await this.ensureConnected()
await this.client!.updateSession(sessionId, {
auto_accept_edits: settings.auto_accept_edits,
})
return { success: true }
// The SDK client expects camelCase for some fields but the method signature uses snake_case
const payload: any = {}
if (settings.auto_accept_edits !== undefined) {
payload.auto_accept_edits = settings.auto_accept_edits
}
if (settings.dangerously_skip_permissions !== undefined) {
payload.dangerouslySkipPermissions = settings.dangerously_skip_permissions
}
if (settings.dangerously_skip_permissions_timeout_ms !== undefined) {
payload.dangerouslySkipPermissionsTimeoutMs = settings.dangerously_skip_permissions_timeout_ms
}
logger.log('Sending updateSession request', { sessionId, payload })
try {
await this.client!.updateSession(sessionId, payload)
return { success: true }
} catch (error) {
logger.error('updateSession failed', { error, sessionId, payload })
throw error
}
}
async archiveSession(

View File

@@ -6,7 +6,7 @@ import type {
Event,
EventType,
RecentPath as SDKRecentPath,
Session,
Session as SDKSession,
Approval,
ConversationEvent,
} from '@humanlayer/hld-sdk'
@@ -17,7 +17,10 @@ export type { Event, EventType }
export type RecentPath = SDKRecentPath
// Export SDK types directly
export type { Session, Approval } from '@humanlayer/hld-sdk'
export type { Approval } from '@humanlayer/hld-sdk'
// Use SDK Session type directly with camelCase naming
export type Session = SDKSession
// Export SDK ConversationEvent type directly
export type { ConversationEvent } from '@humanlayer/hld-sdk'
@@ -67,7 +70,11 @@ export interface DaemonClient {
interruptSession(sessionId: string): Promise<{ success: boolean }>
updateSessionSettings(
sessionId: string,
settings: { auto_accept_edits?: boolean },
settings: {
auto_accept_edits?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout_ms?: number
},
): Promise<{ success: boolean }>
archiveSession(
sessionIdOrRequest: string | { session_id: string; archived: boolean },
@@ -146,6 +153,8 @@ export interface LaunchSessionRequest {
disallowed_tools?: string[]
custom_instructions?: string
verbose?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout?: number
}
export interface LaunchSessionResponse {
@@ -220,9 +229,22 @@ export interface SessionStatusChangedEventData {
new_status: SessionStatus
}
// Constants for session settings change reasons
export const SessionSettingsChangeReason = {
EXPIRED: 'expired', // Dangerous skip permissions expired due to timeout
} as const
export type SessionSettingsChangeReasonType =
(typeof SessionSettingsChangeReason)[keyof typeof SessionSettingsChangeReason]
export interface SessionSettingsChangedEventData {
session_id: string
auto_accept_edits: boolean
event_type?: string
auto_accept_edits?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout_ms?: number
reason?: SessionSettingsChangeReasonType
expired_at?: string // Timestamp when the dangerous skip permissions expired
}
// Conversation types
@@ -305,6 +327,8 @@ export interface InterruptSessionResponse {
export interface UpdateSessionSettingsRequest {
session_id: string
auto_accept_edits?: boolean
dangerously_skip_permissions?: boolean
dangerously_skip_permissions_timeout_ms?: number
}
export interface UpdateSessionSettingsResponse {
@@ -355,3 +379,12 @@ export interface UpdateSessionTitleRequest {
export interface UpdateSessionTitleResponse {
success: boolean
}
// Helper function to ensure SDK Session has proper defaults
export function transformSDKSession(sdkSession: SDKSession): Session {
// SDK Session already has the correct camelCase fields, just ensure defaults
return {
...sdkSession,
dangerouslySkipPermissions: sdkSession.dangerouslySkipPermissions ?? false,
}
}

View File

@@ -53,9 +53,12 @@ export function SessionDetailPage() {
// Render SessionDetail even during loading so it can show its skeleton UI
// Pass a minimal session object if still loading
// Get the session from store if available for most up-to-date state
const sessionFromStore = useStore(state => state.sessions.find(s => s.id === sessionId))
let session = activeSessionDetail?.session?.id
? activeSessionDetail.session
: {
: sessionFromStore || {
id: sessionId || '',
runId: '',
query: '',
@@ -65,6 +68,8 @@ export function SessionDetailPage() {
lastActivityAt: new Date(),
summary: '',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
}
return (

View File

@@ -22,6 +22,8 @@ function createMockSession(
model: 'claude-3-5-sonnet-20241022',
workingDir: '/home/user/project',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
...overrides,
}
}

View File

@@ -198,6 +198,8 @@ export function createDemoAppStore(isDemo: boolean = false): StoreApi<DemoAppSta
model: 'claude-3-5-sonnet-20241022',
workingDir: '/demo/working/dir',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
}
set(currentState => ({

View File

@@ -53,6 +53,8 @@ export function createMockSession(
model: 'claude-3-5-sonnet-20241022',
workingDir: '/home/user/project',
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
...overrides,
}
}

View File

@@ -17,6 +17,8 @@ export function createMockSession(overrides: Partial<Session> = {}): Session {
lastActivityAt: timestamp,
archived: false,
autoAcceptEdits: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissionsExpiresAt: undefined,
...overrides,
}
}