package server import ( "context" "errors" "fmt" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/danielgtaylor/huma/v2" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/service" "go.kenn.io/agentsview/internal/sessionwatch" ) func (s *Server) registerSessionRoutes() { group := newRouteGroup(s.api, "/api/v1", "Sessions") get(s, group, "/sessions", "List sessions", s.humaListSessions) get(s, group, "/sessions/sidebar-index", "List sidebar sessions", s.humaSidebarSessionIndex) get(s, group, "/session-ids/resolve", "Resolve session IDs", s.humaResolveSessionIDs) get(s, group, "/sessions/{id}", "Get session", s.humaGetSession) get(s, group, "/sessions/{id}/messages", "List session messages", s.humaGetMessages) get(s, group, "/sessions/{id}/tool-calls", "List session tool calls", s.humaToolCalls) get(s, group, "/sessions/{id}/children", "List child sessions", s.humaGetChildSessions) get(s, group, "/sessions/{id}/activity", "Get session activity", s.humaGetSessionActivity) get(s, group, "/sessions/{id}/timing", "Get session timing", s.humaSessionTiming) get(s, group, "/sessions/{id}/usage", "Get session usage", s.humaSessionUsage) stream(s, group, http.MethodGet, "/sessions/{id}/watch", "Watch session events", s.humaWatchSession) stream(s, group, http.MethodGet, "/events", "Watch server events", s.humaEvents) raw(s, group, http.MethodGet, "/sessions/{id}/export", "Export session as HTML", s.humaExportSession) raw(s, group, http.MethodGet, "/sessions/{id}/md", "Export session as Markdown", s.humaMarkdownSession) post(s, group, "/sessions/{id}/publish", "Publish session", s.humaPublishSession) post(s, group, "/sessions/{id}/resume", "Resume session", s.humaResumeSession) get(s, group, "/sessions/{id}/directory", "Get session directory", s.humaGetSessionDir) get(s, group, "/sessions/{id}/search", "Search within a session", s.humaSearchSession) post(s, group, "/sessions/{id}/open", "Open session directory", s.humaOpenSession) post(s, group, "/sessions/upload", "Upload a session export", s.humaUploadSession) patch(s, group, "/sessions/{id}/rename", "Rename session", s.humaRenameSession) post(s, group, "/sessions/batch-delete", "Batch delete sessions", s.humaBatchDeleteSessions) deleteRoute(s, group, "/sessions/{id}", "Delete session", s.humaDeleteSession) post(s, group, "/sessions/{id}/restore", "Restore session", s.humaRestoreSession) deleteRoute(s, group, "/sessions/{id}/permanent", "Permanently delete session", s.humaPermanentDeleteSession) get(s, group, "/trash", "List trash", s.humaListTrash) deleteRoute(s, group, "/trash", "Empty trash", s.humaEmptyTrash) } type messageDirection string type markdownDepth string type sessionFilterInput struct { Project string `query:"project" doc:"Filter by project"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` Machine string `query:"machine" doc:"Filter by machine"` GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter sessions active on this YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter sessions active on or after this date"` DateTo string `query:"date_to" format:"date" doc:"Filter sessions active on or before this date"` ActiveSince string `query:"active_since" format:"date-time" doc:"Filter sessions active since this RFC3339 timestamp"` MinMessages int `query:"min_messages" minimum:"0" doc:"Minimum total message count"` MaxMessages int `query:"max_messages" minimum:"0" doc:"Maximum total message count"` MinUserMessages int `query:"min_user_messages" minimum:"0" doc:"Minimum user message count"` IncludeOneShot bool `query:"include_one_shot" doc:"Include one-shot sessions"` IncludeAutomated bool `query:"include_automated" doc:"Include automated sessions"` IncludeChildren bool `query:"include_children" doc:"Include child sessions"` Outcome string `query:"outcome" doc:"Filter by detected outcome"` HealthGrade string `query:"health_grade" doc:"Filter by health grade"` Cursor string `query:"cursor" doc:"Opaque pagination cursor"` Limit int `query:"limit" minimum:"0" doc:"Maximum number of results"` Termination string `query:"termination" doc:"Filter by termination reason"` MinToolFailures optionalIntParam `query:"min_tool_failures" minimum:"0" doc:"Minimum tool failure count"` HasSecret bool `query:"has_secret" doc:"Filter sessions with secret findings"` Starred bool `query:"starred" doc:"Filter sessions by starred status"` OrderBy string `query:"order_by" default:"recent" doc:"Sort order: a comma-separated list of keys, each optionally suffixed :asc or :desc (e.g. messages:desc,started:asc). A key with no suffix uses the descending param, then its natural direction. Valid keys: recent, started, messages, user-messages, output-tokens, peak-context, failures, retries, edit-churn, compactions, context-pressure, health, secrets, id."` Descending optionalBoolParam `query:"descending" doc:"Default sort direction for keys in order_by that carry no explicit :asc/:desc suffix"` } type messageListInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Limit int `query:"limit" minimum:"0" doc:"Maximum number of messages"` Direction messageDirection `query:"direction" enum:"asc,desc" doc:"Message ordering direction"` From optionalIntParam `query:"from" minimum:"0" doc:"Starting message ordinal"` Around optionalIntParam `query:"around" minimum:"0" doc:"Center a symmetric window on this ordinal (mutually exclusive with from/direction)"` Before optionalIntParam `query:"before" minimum:"0" doc:"Messages before the around anchor (default 5)"` After optionalIntParam `query:"after" minimum:"0" doc:"Messages after the around anchor (default 5)"` Roles string `query:"roles" doc:"Comma-separated roles to include, e.g. user,assistant"` } type searchSessionInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Query string `query:"q" doc:"Search query"` } type resolveSessionIDsInput struct { Partial string `query:"partial" required:"true" doc:"Session ID substring"` Limit int `query:"limit" minimum:"0" maximum:"1000" doc:"Maximum number of matching IDs"` } type resolveSessionIDsResponse struct { IDs []string `json:"ids"` } func (in *sessionFilterInput) listFilter() (service.ListFilter, error) { if err := validateDateFilterValues(in.Date, in.DateFrom, in.DateTo, in.ActiveSince); err != nil { return service.ListFilter{}, err } if _, err := db.ParseSortSpec(in.OrderBy); err != nil { return service.ListFilter{}, apiError(http.StatusBadRequest, "invalid order_by: "+err.Error()) } limit := clampLimit(in.Limit, db.DefaultSessionLimit, db.MaxSessionLimit) filter := service.ListFilter{ Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, DateTo: in.DateTo, ActiveSince: in.ActiveSince, MinMessages: in.MinMessages, MaxMessages: in.MaxMessages, MinUserMessages: in.MinUserMessages, IncludeOneShot: in.IncludeOneShot, IncludeAutomated: in.IncludeAutomated, IncludeChildren: in.IncludeChildren, Outcome: in.Outcome, HealthGrade: in.HealthGrade, Cursor: in.Cursor, Limit: limit, Termination: in.Termination, HasSecret: in.HasSecret, Starred: in.Starred, OrderBy: in.OrderBy, Descending: optionalBoolValue(in.Descending), } if in.MinToolFailures.IsSet { filter.MinToolFailures = &in.MinToolFailures.Value } return filter, nil } func (in *sessionFilterInput) dbFilter(includeChildren bool) (db.SessionFilter, error) { if err := validateDateFilterValues(in.Date, in.DateFrom, in.DateTo, in.ActiveSince); err != nil { return db.SessionFilter{}, err } // The order_by param is shared with the list route via this struct; reject // malformed specs here too (the dropped enum used to guard every route), // even though the sidebar index applies its own ordering and ignores it. if _, err := db.ParseSortSpec(in.OrderBy); err != nil { return db.SessionFilter{}, apiError(http.StatusBadRequest, "invalid order_by: "+err.Error()) } limit := 0 if in.Limit > 0 { limit = clampLimit(in.Limit, db.DefaultSessionLimit, db.MaxSessionLimit) } return db.SessionFilter{ Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, DateTo: in.DateTo, ActiveSince: in.ActiveSince, MinMessages: in.MinMessages, MaxMessages: in.MaxMessages, MinUserMessages: in.MinUserMessages, ExcludeOneShot: !in.IncludeOneShot, ExcludeAutomated: !in.IncludeAutomated, IncludeChildren: includeChildren, Cursor: in.Cursor, Limit: limit, Termination: in.Termination, Starred: in.Starred, }, nil } func (s *Server) humaListSessions( ctx context.Context, in *sessionFilterInput, ) (*jsonOutput[*service.SessionList], error) { filter, err := in.listFilter() if err != nil { return nil, err } page, err := s.sessions.List(ctx, filter) if err != nil { if errors.Is(err, db.ErrInvalidCursor) { return nil, apiError(http.StatusBadRequest, "invalid cursor") } return nil, serverError(err) } return &jsonOutput[*service.SessionList]{Body: page}, nil } func (s *Server) humaSidebarSessionIndex( ctx context.Context, in *sessionFilterInput, ) (*jsonOutput[db.SidebarSessionIndex], error) { filter, err := in.dbFilter(true) if err != nil { return nil, err } index, err := s.db.GetSidebarSessionIndex(ctx, filter) if err != nil { if errors.Is(err, db.ErrInvalidCursor) { return nil, apiError(http.StatusBadRequest, "invalid cursor") } return nil, serverError(err) } return &jsonOutput[db.SidebarSessionIndex]{Body: index}, nil } func (s *Server) humaResolveSessionIDs( ctx context.Context, in *resolveSessionIDsInput, ) (*jsonOutput[resolveSessionIDsResponse], error) { ids, err := s.sessions.FindSessionIDsByPartial(ctx, in.Partial, in.Limit) if err != nil { if handled := handleHumaContextError(err); handled != nil { return nil, handled } if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, serverError(err) } return &jsonOutput[resolveSessionIDsResponse]{ Body: resolveSessionIDsResponse{IDs: ids}, }, nil } func (s *Server) humaGetSession( ctx context.Context, in *idPathInput, ) (*jsonOutput[*service.SessionDetail], error) { detail, err := s.sessions.Get(ctx, in.ID) if err != nil { return nil, serverError(err) } if detail == nil { return nil, apiError(http.StatusNotFound, "session not found") } return &jsonOutput[*service.SessionDetail]{Body: detail}, nil } func (s *Server) humaGetChildSessions( ctx context.Context, in *idPathInput, ) (*jsonOutput[[]db.Session], error) { children, err := s.db.GetChildSessions(ctx, in.ID) if err != nil { return nil, serverError(err) } if children == nil { children = []db.Session{} } return &jsonOutput[[]db.Session]{Body: children}, nil } func (s *Server) humaGetMessages( ctx context.Context, in *messageListInput, ) (*jsonOutput[*service.MessageList], error) { limit := clampLimit(in.Limit, db.DefaultMessageLimit, db.MaxMessageLimit) filter := service.MessageFilter{ Limit: limit, Direction: string(in.Direction), } if in.From.IsSet { filter.From = &in.From.Value } if in.Around.IsSet { filter.Around = &in.Around.Value } if in.Before.IsSet { filter.Before = &in.Before.Value } if in.After.IsSet { filter.After = &in.After.Value } if in.Roles != "" { filter.Roles = splitTrimmedNonEmpty(in.Roles) } list, err := s.sessions.Messages(ctx, in.ID, filter) if err != nil { if errors.Is(err, service.ErrAroundMutuallyExclusive) || errors.Is(err, service.ErrBeforeAfterRequireAround) { return nil, apiError(http.StatusBadRequest, err.Error()) } return nil, serverError(err) } return &jsonOutput[*service.MessageList]{Body: list}, nil } // splitTrimmedNonEmpty splits s on commas, trims surrounding whitespace from // each part, and drops empty parts. This matches the CLI's `session search // --in` convention (cmd/agentsview/session_search.go) so a trailing or // doubled comma (e.g. "user,") narrows the filter by one intended value // instead of silently adding a spurious "" element that matches nothing. func splitTrimmedNonEmpty(s string) []string { var out []string for part := range strings.SplitSeq(s, ",") { if part = strings.TrimSpace(part); part != "" { out = append(out, part) } } return out } func (s *Server) humaToolCalls( ctx context.Context, in *idPathInput, ) (*jsonOutput[*service.ToolCallList], error) { list, err := s.sessions.ToolCalls(ctx, in.ID) if err != nil { return nil, serverError(err) } return &jsonOutput[*service.ToolCallList]{Body: list}, nil } func (s *Server) humaGetSessionActivity( ctx context.Context, in *idPathInput, ) (*jsonOutput[*db.SessionActivityResponse], error) { resp, err := s.db.GetSessionActivity(ctx, in.ID) if err != nil { return nil, serverError(err) } return &jsonOutput[*db.SessionActivityResponse]{Body: resp}, nil } func (s *Server) humaSessionTiming( ctx context.Context, in *idPathInput, ) (*jsonOutput[*db.SessionTiming], error) { timing, err := s.db.GetSessionTiming(ctx, in.ID) if err != nil { return nil, serverError(err) } if timing == nil { return nil, apiError(http.StatusNotFound, "session not found") } return &jsonOutput[*db.SessionTiming]{Body: timing}, nil } type sessionUsageResponse struct { SessionID string `json:"session_id"` Agent string `json:"agent"` Project string `json:"project"` TotalOutputTokens int `json:"total_output_tokens"` PeakContextTokens int `json:"peak_context_tokens"` HasTokenData bool `json:"has_token_data"` CostUSD float64 `json:"cost_usd"` HasCost bool `json:"has_cost"` AICredits float64 `json:"ai_credits,omitempty"` Models []string `json:"models"` UnpricedModels []string `json:"unpriced_models"` BreakdownCount int `json:"breakdown_count"` Breakdown []sessionUsageBreakdownResponse `json:"breakdown"` ServerRunning bool `json:"server_running"` } type sessionUsageInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Breakdown bool `query:"breakdown" doc:"Include per-step breakdown rows"` } type sessionUsageBreakdownResponse struct { Ordinal int `json:"ordinal"` MessageOrdinal *int `json:"message_ordinal,omitempty"` Source string `json:"source"` Label string `json:"label"` Timestamp string `json:"timestamp"` Model string `json:"model"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` CostUSD float64 `json:"cost_usd"` HasCost bool `json:"has_cost"` } type sessionUsageErrorBody struct { Code string `json:"code"` Message string `json:"message"` } type sessionUsageError struct { Status int `json:"-"` Body sessionUsageErrorBody `json:"error"` } func (e *sessionUsageError) Error() string { return e.Body.Message } func (e *sessionUsageError) GetStatus() int { return e.Status } func newSessionUsageHumaResponse(usage *db.SessionUsage) sessionUsageResponse { unpricedModels := usage.UnpricedModels if unpricedModels == nil { unpricedModels = []string{} } breakdown := make([]sessionUsageBreakdownResponse, 0, len(usage.Breakdown)) for _, entry := range usage.Breakdown { breakdown = append(breakdown, sessionUsageBreakdownResponse{ Ordinal: entry.Ordinal, MessageOrdinal: entry.MessageOrdinal, Source: entry.Source, Label: entry.Label, Timestamp: entry.Timestamp, Model: entry.Model, InputTokens: entry.InputTokens, OutputTokens: entry.OutputTokens, CacheCreationInputTokens: entry.CacheCreationInputTokens, CacheReadInputTokens: entry.CacheReadInputTokens, CostUSD: entry.CostUSD, HasCost: entry.HasCost, }) } return sessionUsageResponse{ SessionID: usage.SessionID, Agent: usage.Agent, Project: usage.Project, TotalOutputTokens: usage.TotalOutputTokens, PeakContextTokens: usage.PeakContextTokens, HasTokenData: usage.HasTokenData, CostUSD: usage.CostUSD, HasCost: usage.HasCost, AICredits: usage.AICredits, Models: usage.Models, UnpricedModels: unpricedModels, BreakdownCount: usage.BreakdownCount, Breakdown: breakdown, ServerRunning: true, } } func (s *Server) humaSessionUsage( ctx context.Context, in *sessionUsageInput, ) (*jsonOutput[sessionUsageResponse], error) { usage, err := s.db.GetSessionUsage(ctx, in.ID, in.Breakdown) if err != nil { if handled := handleHumaContextError(err); handled != nil { return nil, handled } return nil, &sessionUsageError{ Status: http.StatusInternalServerError, Body: sessionUsageErrorBody{ Code: "usage_query_failed", Message: "failed to query session usage", }, } } if usage == nil { return nil, &sessionUsageError{ Status: http.StatusNotFound, Body: sessionUsageErrorBody{ Code: "session_not_found", Message: "session not found", }, } } return &jsonOutput[sessionUsageResponse]{ Body: newSessionUsageHumaResponse(usage), }, nil } func (s *Server) humaSearchSession( ctx context.Context, in *searchSessionInput, ) (*jsonOutput[ordinalsResponse], error) { if in.Query == "" { return &jsonOutput[ordinalsResponse]{Body: ordinalsResponse{Ordinals: []int{}}}, nil } ordinals, err := s.db.SearchSession(ctx, in.ID, in.Query) if err != nil { return nil, serverError(err) } if ordinals == nil { ordinals = []int{} } return &jsonOutput[ordinalsResponse]{Body: ordinalsResponse{Ordinals: ordinals}}, nil } type ordinalsResponse struct { Ordinals []int `json:"ordinals"` } type sessionDirectoryResponse struct { Path string `json:"path"` } type openSessionInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Body openRequest } type openSessionResponse struct { Launched bool `json:"launched"` Opener string `json:"opener"` Path string `json:"path"` } type publishSessionInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Secret bool `query:"secret" doc:"Create a secret gist instead of a public one"` } type publishResponse struct { GistID string `json:"gist_id"` GistURL string `json:"gist_url"` ViewURL string `json:"view_url"` RawURL string `json:"raw_url"` } type renameSessionInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Body renameRequest } type renameRequest struct { DisplayName *string `json:"display_name"` } type trashResponse struct { Sessions []db.Session `json:"sessions"` } type emptyTrashResponse struct { Deleted int `json:"deleted"` } func (s *Server) humaGetSessionDir( ctx context.Context, in *idPathInput, ) (*jsonOutput[sessionDirectoryResponse], error) { if s.db.ReadOnly() { return nil, apiError(http.StatusNotImplemented, "not available in remote mode") } session, err := s.db.GetSessionFull(ctx, in.ID) if err != nil { return nil, internalError("get session directory", err) } if session == nil || session.DeletedAt != nil { return nil, apiError(http.StatusNotFound, "session not found") } return &jsonOutput[sessionDirectoryResponse]{ Body: sessionDirectoryResponse{Path: resolveSessionDir(session)}, }, nil } func (s *Server) humaOpenSession( ctx context.Context, in *openSessionInput, ) (*jsonOutput[openSessionResponse], error) { if s.db.ReadOnly() { return nil, apiError(http.StatusNotImplemented, "not available in remote mode") } session, err := s.db.GetSessionFull(ctx, in.ID) if err != nil { return nil, internalError("open session lookup", err) } if session == nil || session.DeletedAt != nil { return nil, apiError(http.StatusNotFound, "session not found") } projectDir := resolveSessionDir(session) if projectDir == "" { return nil, apiError(http.StatusBadRequest, "session has no project directory") } openers := detectOpeners() var opener *Opener for i := range openers { if openers[i].ID == in.Body.OpenerID { opener = &openers[i] break } } if opener == nil { return nil, apiError(http.StatusBadRequest, fmt.Sprintf("opener %q not found", in.Body.OpenerID)) } if err := launchOpener(*opener, projectDir); err != nil { return nil, apiError(http.StatusInternalServerError, "failed to launch") } return &jsonOutput[openSessionResponse]{ Body: openSessionResponse{ Launched: true, Opener: opener.Name, Path: projectDir, }, }, nil } func (s *Server) humaPublishSession( ctx context.Context, in *publishSessionInput, ) (*jsonOutput[publishResponse], error) { token := s.githubToken(ctx) if token == "" { return nil, apiError(http.StatusUnauthorized, "GitHub token not configured") } session, msgs, err := s.sessionWithMessages(ctx, in.ID) if err != nil { return nil, err } htmlContent := generateExportHTML(session, msgs) filename := session.Project + "-" + formatDateShort(session.StartedAt) + ".html" first := "" if session.FirstMessage != nil { first = truncateStr(*session.FirstMessage, 100) } description := fmt.Sprintf("Agent session: %s - %s", session.Project, first) gist, err := createGist(ctx, token, filename, description, htmlContent, !in.Secret) if err != nil { return nil, apiError(http.StatusBadGateway, err.Error()) } if gist.ID == "" || gist.HTMLURL == "" { return nil, apiError(http.StatusBadGateway, "GitHub API returned incomplete gist data") } encoded := urlPathEscape(filename) rawURL := fmt.Sprintf( "https://gist.githubusercontent.com/%s/%s/raw/%s", gist.Owner.Login, gist.ID, encoded, ) return &jsonOutput[publishResponse]{ Body: publishResponse{ GistID: gist.ID, GistURL: gist.HTMLURL, ViewURL: "https://htmlpreview.github.io/?" + rawURL, RawURL: rawURL, }, }, nil } func urlPathEscape(s string) string { return url.PathEscape(s) } func (s *Server) humaRenameSession( ctx context.Context, in *renameSessionInput, ) (*jsonOutput[*db.Session], error) { session, err := s.db.GetSession(ctx, in.ID) if err != nil { return nil, internalError("rename session lookup", err) } if session == nil { return nil, apiError(http.StatusNotFound, "session not found") } displayName := in.Body.DisplayName if displayName != nil && *displayName == "" { displayName = nil } if err := s.db.RenameSession(in.ID, displayName); err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("rename session", err) } updated, err := s.db.GetSession(ctx, in.ID) if err != nil { return nil, internalError("rename session readback", err) } if updated == nil { return nil, apiError(http.StatusNotFound, "session not found") } return &jsonOutput[*db.Session]{Body: updated}, nil } func (s *Server) humaDeleteSession( ctx context.Context, in *idPathInput, ) (*noContentOutput, error) { session, err := s.db.GetSessionFull(ctx, in.ID) if err != nil { return nil, internalError("delete session lookup", err) } if session == nil { return nil, apiError(http.StatusNotFound, "session not found") } if err := s.db.SoftDeleteSession(in.ID); err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("soft delete session", err) } return &noContentOutput{Status: http.StatusNoContent}, nil } type batchDeleteInput struct { Body struct { SessionIDs []string `json:"session_ids" required:"true" nullable:"false" doc:"Session IDs to soft-delete"` } } func (s *Server) humaBatchDeleteSessions( _ context.Context, in *batchDeleteInput, ) (*noContentOutput, error) { if len(in.Body.SessionIDs) == 0 { return &noContentOutput{Status: http.StatusNoContent}, nil } if _, err := s.db.SoftDeleteSessions(in.Body.SessionIDs); err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("batch delete sessions", err) } return &noContentOutput{Status: http.StatusNoContent}, nil } func (s *Server) humaRestoreSession( _ context.Context, in *idPathInput, ) (*noContentOutput, error) { n, err := s.db.RestoreSession(in.ID) if err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("restore session", err) } if n == 0 { return nil, apiError(http.StatusNotFound, "session not found or not in trash") } return &noContentOutput{Status: http.StatusNoContent}, nil } func (s *Server) humaPermanentDeleteSession( _ context.Context, in *idPathInput, ) (*noContentOutput, error) { n, err := s.db.DeleteSessionIfTrashed(in.ID) if err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("permanent delete session", err) } if n == 0 { return nil, apiError(http.StatusConflict, "session not found or not in trash") } return &noContentOutput{Status: http.StatusNoContent}, nil } func (s *Server) humaListTrash( ctx context.Context, _ *emptyInput, ) (*jsonOutput[trashResponse], error) { sessions, err := s.db.ListTrashedSessions(ctx) if err != nil { return nil, internalError("list trashed sessions", err) } return &jsonOutput[trashResponse]{Body: trashResponse{Sessions: sessions}}, nil } func (s *Server) humaEmptyTrash( _ context.Context, _ *emptyInput, ) (*jsonOutput[emptyTrashResponse], error) { count, err := s.db.EmptyTrash() if err != nil { if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } return nil, internalError("empty trash", err) } return &jsonOutput[emptyTrashResponse]{Body: emptyTrashResponse{Deleted: count}}, nil } type uploadSessionInput struct { Project string `query:"project" required:"true" doc:"Project for imported session"` Machine string `query:"machine" default:"remote" doc:"Machine name for imported session"` RawBody huma.MultipartFormFiles[uploadSessionForm] } type uploadSessionForm struct { File huma.FormFile `form:"file" contentType:"application/octet-stream" required:"true"` } type uploadSessionResponse struct { SessionID string `json:"session_id"` Project string `json:"project"` Machine string `json:"machine"` Messages int `json:"messages"` Sessions int `json:"sessions"` } type markdownInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Depth markdownDepth `query:"depth" enum:"1,all" doc:"Child session depth"` } func (s *Server) sessionWithMessages( ctx context.Context, id string, ) (*db.Session, []db.Message, error) { session, err := s.db.GetSession(ctx, id) if err != nil { return nil, nil, apiError(http.StatusInternalServerError, err.Error()) } if session == nil { return nil, nil, apiError(http.StatusNotFound, "session not found") } msgs, err := s.db.GetAllMessages(ctx, id) if err != nil { return nil, nil, apiError(http.StatusInternalServerError, err.Error()) } return session, msgs, nil } func (s *Server) humaExportSession( ctx context.Context, in *idPathInput, ) (*bytesOutput, error) { session, msgs, err := s.sessionWithMessages(ctx, in.ID) if err != nil { return nil, err } htmlContent := generateExportHTML(session, msgs) filename := sanitizeFilename(session.Project + "-" + formatDateShort(session.StartedAt) + ".html") return &bytesOutput{ ContentType: "text/html; charset=utf-8", ContentDisposition: fmt.Sprintf(`attachment; filename="%s"`, filename), Body: []byte(htmlContent), }, nil } func (s *Server) humaMarkdownSession( ctx context.Context, in *markdownInput, ) (*bytesOutput, error) { depth := string(in.Depth) tree, err := s.loadExportSessionTree(ctx, in.ID, depth, map[string]bool{}, 0) if err != nil { return nil, serverError(err) } if tree == nil || tree.Session == nil { return nil, apiError(http.StatusNotFound, "session not found") } md := generateExportMarkdownTree(tree, exportMarkdownOptions{Depth: depth}) filename := sanitizeFilename(tree.Session.Project + "-" + formatDateShort(tree.Session.StartedAt) + ".md") return &bytesOutput{ ContentType: "text/markdown; charset=utf-8", ContentDisposition: fmt.Sprintf(`inline; filename="%s"`, filename), Body: []byte(md), }, nil } func (s *Server) humaWatchSession( ctx context.Context, in *idPathInput, ) (*huma.StreamResponse, error) { sess, err := s.sessions.Get(ctx, in.ID) if err != nil { return nil, serverError(err) } if sess == nil { return nil, apiError(http.StatusNotFound, "session not found: "+in.ID) } return &huma.StreamResponse{Body: func(hctx huma.Context) { stream, ok := newHumaSSEStream(hctx) if !ok { writeHumaJSON(hctx, http.StatusInternalServerError, apiErrorResponse{Message: "streaming not supported"}) return } streamCtx := hctx.Context() updates := s.sessionMonitor(streamCtx, in.ID) heartbeat := time.NewTicker( sessionwatch.PollInterval * sessionwatch.HeartbeatTicks, ) defer heartbeat.Stop() if t, err := s.db.GetSessionTiming(streamCtx, in.ID); err != nil { log.Printf("session timing initial: %v", err) } else if t != nil { stream.SendJSON("session.timing", t) } for { select { case <-streamCtx.Done(): return case _, ok := <-updates: if !ok { return } stream.Send("session_updated", in.ID) if t, err := s.db.GetSessionTiming(streamCtx, in.ID); err != nil { log.Printf("session timing update: %v", err) } else if t != nil { stream.SendJSON("session.timing", t) } case <-heartbeat.C: stream.Send("heartbeat", time.Now().UTC().Format(time.RFC3339)) } } }}, nil } func (s *Server) humaEvents( _ context.Context, _ *emptyInput, ) (*huma.StreamResponse, error) { if s.broadcaster == nil { return nil, huma.ErrorWithHeaders( apiError(http.StatusServiceUnavailable, "events not available in this mode"), http.Header{"Retry-After": []string{"300"}}, ) } return &huma.StreamResponse{Body: func(hctx huma.Context) { stream, ok := newHumaSSEStream(hctx) if !ok { writeHumaJSON(hctx, http.StatusInternalServerError, apiErrorResponse{Message: "streaming not supported"}) return } sub, unsub := s.broadcaster.Subscribe() defer unsub() heartbeat := time.NewTicker( sessionwatch.PollInterval * sessionwatch.HeartbeatTicks, ) defer heartbeat.Stop() for { select { case <-hctx.Context().Done(): return case ev, ok := <-sub: if !ok { return } stream.SendJSON("data_changed", map[string]string{"scope": ev.Scope}) case <-heartbeat.C: stream.Send("heartbeat", time.Now().Format(time.RFC3339)) } } }}, nil } func (s *Server) humaUploadSession( ctx context.Context, in *uploadSessionInput, ) (*jsonOutput[uploadSessionResponse], error) { if s.db.ReadOnly() { return nil, apiError(http.StatusNotImplemented, "uploads are not available in read-only mode") } project := strings.TrimSpace(in.Project) if project == "" { return nil, apiError(http.StatusBadRequest, "project required") } if !isSafeName(project) { return nil, apiError(http.StatusBadRequest, "invalid project name") } machine := in.Machine if machine == "" { machine = "remote" } file := in.RawBody.Data().File if !file.IsSet { return nil, apiError(http.StatusBadRequest, "file field required") } defer file.Close() if !strings.HasSuffix(file.Filename, ".jsonl") { return nil, apiError(http.StatusBadRequest, "file must be .jsonl") } safeName := filepath.Base(file.Filename) if safeName != file.Filename || !isSafeName(strings.TrimSuffix(safeName, ".jsonl")) { return nil, apiError(http.StatusBadRequest, "invalid filename") } upload, err := s.stageUpload(project, safeName, file) if err != nil { log.Printf("Error saving upload: %v", err) return nil, apiError(http.StatusInternalServerError, "failed to save upload") } defer func() { _ = os.RemoveAll(upload.tempDir) }() provider, ok := parser.NewProvider( parser.AgentClaude, parser.ProviderConfig{Machine: machine}, ) if !ok { return nil, apiError(http.StatusInternalServerError, "claude provider unavailable") } uploader, ok := provider.(parser.ClaudeUploadParser) if !ok { return nil, apiError(http.StatusInternalServerError, "claude provider does not support uploads") } results, err := uploader.ParseUploadedTranscript( upload.tempPath, project, machine, ) if err != nil { return nil, apiError(http.StatusBadRequest, fmt.Sprintf("parsing session: %v", err)) } if len(results) == 0 { return nil, apiError(http.StatusBadRequest, "no sessions parsed from upload") } for i := range results { results[i].Session.File.Path = upload.finalPath } writes := make([]db.SessionBatchWrite, len(results)) for i, pr := range results { writes[i] = sessionBatchWriteFromParsed(pr.Session, pr.Messages) } var commitErr error var uploadCommit committedUpload _, err = s.db.WriteSessionBatchAtomic(writes, func() error { uploadCommit, commitErr = commitUpload(upload) return commitErr }) if err != nil { if commitErr != nil { log.Printf("Error committing upload: %v", commitErr) return nil, apiError(http.StatusInternalServerError, "failed to save upload") } if uploadCommit.movedFinal { if rbErr := rollbackCommittedUpload(uploadCommit); rbErr != nil { log.Printf("Error rolling back upload after DB failure: %v", rbErr) return nil, apiError(http.StatusInternalServerError, "failed to save upload") } cleanupCommittedUpload(uploadCommit) } if handled := handleHumaReadOnly(err); handled != nil { return nil, handled } if errors.Is(err, db.ErrSessionExcluded) || errors.Is(err, db.ErrSessionTrashed) { return nil, apiError(http.StatusConflict, "session upload rejected: session is excluded or trashed") } log.Printf("Error saving session to DB: %v", err) return nil, apiError(http.StatusInternalServerError, "failed to save session to database") } cleanupCommittedUpload(uploadCommit) main := results[0] return &jsonOutput[uploadSessionResponse]{ Body: uploadSessionResponse{ SessionID: main.Session.ID, Project: project, Machine: machine, Messages: len(main.Messages), Sessions: len(results), }, }, nil } type resumeSessionInput struct { ID string `path:"id" required:"true" doc:"Session ID"` Body resumeRequest } func (s *Server) humaResumeSession( ctx context.Context, in *resumeSessionInput, ) (*jsonOutput[resumeResponse], error) { session, err := s.db.GetSessionFull(ctx, in.ID) if err != nil { return nil, internalError("resume: session lookup failed", err) } if session == nil || session.DeletedAt != nil { return nil, apiError(http.StatusNotFound, "session not found") } if host, _ := parser.StripHostPrefix(in.ID); host != "" { return nil, apiError(http.StatusBadRequest, "cannot resume remote session") } tmpl, ok := resumeAgents[string(session.Agent)] if !ok { return nil, apiError(http.StatusBadRequest, fmt.Sprintf("agent %q does not support resume", session.Agent)) } req := in.Body if req.FromOrdinal != nil { if string(session.Agent) != "claude" { return nil, apiError(http.StatusBadRequest, "message-point fork is only available for Claude sessions") } if !req.ForkSession { return nil, apiError(http.StatusBadRequest, "message-point fork requires fork_session") } if req.OpenerID != "" { return nil, apiError(http.StatusBadRequest, "message-point fork does not support opener_id") } if *req.FromOrdinal < 0 { return nil, apiError(http.StatusBadRequest, "from_ordinal must be non-negative") } msgs, err := s.db.GetAllMessages(ctx, session.ID) if err != nil { return nil, internalError("resume: message lookup failed", err) } messageIdx := -1 for i := range msgs { if msgs[i].Ordinal == *req.FromOrdinal { messageIdx = i break } } if messageIdx < 0 { return nil, apiError(http.StatusNotFound, "message not found") } if !req.CommandOnly && s.db.ReadOnly() { return nil, apiError(http.StatusNotImplemented, "session launch not available in remote mode") } promptPath, err := writeClaudeMessagePointPrompt( session, msgs[:messageIdx+1], *req.FromOrdinal, ) if err != nil { return nil, internalError("resume: prompt render failed", err) } launchDir, _ := resolveResumePaths(session) launchCmd := claudeMessagePointLaunchCommand( promptPath, req.SkipPermissions, ) responseCmd := claudeMessagePointResponseCommand( promptPath, req.SkipPermissions, launchDir, runtime.GOOS, ) if req.CommandOnly { return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, }, }, nil } s.mu.RLock() termCfg := s.cfg.Terminal s.mu.RUnlock() if termCfg.Mode == string(terminalModeClipboard) { return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, }, }, nil } detectCwd := launchDir if termCfg.Mode == string(terminalModeAuto) { detectCwd = resumeLaunchCwd( string(session.Agent), "auto", runtime.GOOS, launchDir, ) } termBin, termArgs, termName, termErr := detectTerminal( launchCmd, detectCwd, termCfg, ) if termErr != nil { log.Printf("resume: terminal detection failed: %v", termErr) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "no_terminal_found", }, }, nil } proc := exec.Command(termBin, termArgs...) proc.Stdout = nil proc.Stderr = nil proc.Stdin = nil if detectCwd != "" { proc.Dir = detectCwd } if err := proc.Start(); err != nil { log.Printf("resume: launch failed via %s: %v", termName, err) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "launch_failed", }, }, nil } go func() { _ = proc.Wait() }() return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: true, Terminal: termName, Command: responseCmd, Cwd: launchDir, }, }, nil } prefix := string(session.Agent) + ":" rawID := strings.TrimPrefix(in.ID, prefix) var cmd string if strings.Contains(tmpl, "%s") { cmd = fmt.Sprintf(tmpl, shellQuote(rawID)) } else { cmd = tmpl } if string(session.Agent) == "claude" { if req.SkipPermissions { cmd += " --dangerously-skip-permissions" } if req.ForkSession { cmd += " --fork-session" } } launchDir, workspaceDir := resolveResumePaths(session) if string(session.Agent) == "cursor" && workspaceDir != "" { cmd += " --workspace " + shellQuote(workspaceDir) } responseCmd := cmd switch string(session.Agent) { case "claude", "kiro": responseCmd = commandWithCwd(cmd, launchDir) } if req.CommandOnly { return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, }, }, nil } if s.db.ReadOnly() { return nil, apiError(http.StatusNotImplemented, "session launch not available in remote mode") } if req.OpenerID != "" { return s.humaResumeWithOpener(session, rawID, cmd, responseCmd, launchDir, req.OpenerID) } s.mu.RLock() termCfg := s.cfg.Terminal s.mu.RUnlock() if termCfg.Mode == string(terminalModeClipboard) { return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, }, }, nil } detectCwd := launchDir if termCfg.Mode == string(terminalModeAuto) { detectCwd = resumeLaunchCwd( string(session.Agent), "auto", runtime.GOOS, launchDir, ) } termBin, termArgs, termName, termErr := detectTerminal(cmd, detectCwd, termCfg) if termErr != nil { log.Printf("resume: terminal detection failed: %v", termErr) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "no_terminal_found", }, }, nil } proc := exec.Command(termBin, termArgs...) proc.Stdout = nil proc.Stderr = nil proc.Stdin = nil if detectCwd != "" { proc.Dir = detectCwd } if err := proc.Start(); err != nil { log.Printf("resume: terminal start failed: %v", err) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "terminal_launch_failed", }, }, nil } go func() { _ = proc.Wait() }() return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: true, Terminal: termName, Command: responseCmd, Cwd: launchDir, }, }, nil } func (s *Server) humaResumeWithOpener( session *db.Session, rawID string, cmd string, responseCmd string, launchDir string, openerID string, ) (*jsonOutput[resumeResponse], error) { openers := detectOpeners() var opener *Opener for i := range openers { if openers[i].ID == openerID { opener = &openers[i] break } } if opener == nil { return nil, apiError(http.StatusBadRequest, fmt.Sprintf("opener %q not found", openerID)) } if opener.ID == "claude-desktop" { if string(session.Agent) != "claude" { return nil, apiError(http.StatusBadRequest, "Claude Desktop resume only supports Claude sessions") } proc := launchClaudeDesktop(rawID, launchDir) if err := proc.Start(); err != nil { log.Printf("resume: Claude Desktop launch failed: %v", err) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "desktop_launch_failed", }, }, nil } go func() { _ = proc.Wait() }() return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: true, Terminal: opener.Name, Command: responseCmd, Cwd: launchDir, }, }, nil } openerCwd := resumeLaunchCwd( string(session.Agent), opener.ID, runtime.GOOS, launchDir, ) proc := launchResumeInOpener(*opener, cmd, openerCwd) if proc == nil { return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "unsupported_opener", }, }, nil } if err := proc.Start(); err != nil { log.Printf("resume: opener start failed: %v", err) return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: false, Command: responseCmd, Cwd: launchDir, Error: "terminal_launch_failed", }, }, nil } go func() { _ = proc.Wait() }() return &jsonOutput[resumeResponse]{ Body: resumeResponse{ Launched: true, Terminal: opener.Name, Command: responseCmd, Cwd: launchDir, }, }, nil }