Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions internal/session/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,42 +42,46 @@ var (
bTaskCreateS = []byte(`"name": "TaskCreate"`)
bCronCreate = []byte(`"name":"CronCreate"`)
bCronCreateS = []byte(`"name": "CronCreate"`)
bMonitorTool = []byte(`"name":"Monitor"`)
bMonitorToolS = []byte(`"name": "Monitor"`)
bRunInBackground = []byte(`"run_in_background":true`)
bRunInBackgroundS = []byte(`"run_in_background": true`)

// Fork detection markers
bForkedFrom = []byte(`"forkedFrom"`)

// Stats-specific markers
bUsage = []byte(`"usage":{`)
bUsageS = []byte(`"usage": {`)
bToolUse = []byte(`"type":"tool_use"`)
bToolUseS = []byte(`"type": "tool_use"`)
bIsErrorT = []byte(`"is_error":true`)
bIsErrorTS = []byte(`"is_error": true`)
bToolRes = []byte(`"type":"tool_result"`)
bToolResS = []byte(`"type": "tool_result"`)
bNameQ = []byte(`"name":"`)
bNameQS = []byte(`"name": "`)
bFilePathQ = []byte(`"file_path":"`)
bFilePathS = []byte(`"file_path": "`)
bModelQ = []byte(`"model":"`)
bModelQS = []byte(`"model": "`)
bSkillQ = []byte(`"skill":"`)
bSkillQS = []byte(`"skill": "`)
bUsage = []byte(`"usage":{`)
bUsageS = []byte(`"usage": {`)
bToolUse = []byte(`"type":"tool_use"`)
bToolUseS = []byte(`"type": "tool_use"`)
bIsErrorT = []byte(`"is_error":true`)
bIsErrorTS = []byte(`"is_error": true`)
bToolRes = []byte(`"type":"tool_result"`)
bToolResS = []byte(`"type": "tool_result"`)
bNameQ = []byte(`"name":"`)
bNameQS = []byte(`"name": "`)
bFilePathQ = []byte(`"file_path":"`)
bFilePathS = []byte(`"file_path": "`)
bModelQ = []byte(`"model":"`)
bModelQS = []byte(`"model": "`)
bSkillQ = []byte(`"skill":"`)
bSkillQS = []byte(`"skill": "`)
bSubagentQ = []byte(`"subagent_type":"`)
bSubagentQS = []byte(`"subagent_type": "`)
bCmdTag = []byte(`<command-name>`)
bCmdTagEnd = []byte(`</command-name>`)
bIDCol = []byte(`"id":"`)
bIDColS = []byte(`"id": "`)
bTUIDCol = []byte(`"tool_use_id":"`)
bTUIDColS = []byte(`"tool_use_id": "`)
bIDCol = []byte(`"id":"`)
bIDColS = []byte(`"id": "`)
bTUIDCol = []byte(`"tool_use_id":"`)
bTUIDColS = []byte(`"tool_use_id": "`)

// Hook markers (inside progress lines)
bHookProgress = []byte(`"hook_progress"`)
bHookEvent = []byte(`"hookEvent":"`)
bHookEventS = []byte(`"hookEvent": "`)
bHookCommand = []byte(`"command":"`)
bHookCommandS = []byte(`"command": "`)
bHookProgress = []byte(`"hook_progress"`)
bHookEvent = []byte(`"hookEvent":"`)
bHookEventS = []byte(`"hookEvent": "`)
bHookCommand = []byte(`"command":"`)
bHookCommandS = []byte(`"command": "`)

// Path decode cache
decodedPathCache sync.Map // dirName → decoded path (string, "" if unresolvable)
Expand Down
3 changes: 3 additions & 0 deletions internal/session/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func FilterValueFor(s Session, cwdProjectPaths []string) string {
if s.TmuxWindowName != "" {
parts = append(parts, "win:"+s.TmuxWindowName, s.TmuxWindowName)
}
if s.IsCurrentWindow {
parts = append(parts, "is:here")
}
if s.IsLive {
parts = append(parts, "is:live")
}
Expand Down
92 changes: 56 additions & 36 deletions internal/session/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,39 +18,56 @@ type TaskItem struct {
}

type CronItem struct {
ID string
Cron string
Prompt string
Recurring bool
Status string // active, deleted
CreatedAt time.Time
DeletedAt time.Time
ID string
Cron string
Prompt string
Recurring bool
Status string // active, deleted
CreatedAt time.Time
DeletedAt time.Time
}

// ShellJob represents a long-running shell or monitor invocation discovered in
// the conversation: either a Bash tool call with run_in_background=true, or a
// Monitor tool call. Status reflects the latest lifecycle event we observed.
type ShellJob struct {
ID string // tool_use ID (links BashOutput/KillShell back)
ToolName string // "Bash" or "Monitor"
Command string
Description string
Persistent bool // Monitor.persistent
TimeoutMS int // Bash.timeout (ms) or Monitor.timeout_ms
DangerouslyDisableSandbox bool
StartedAt time.Time
LastEventAt time.Time
PollCount int // BashOutput calls observed against this shell
Status string // "running", "polled", "killed", "stopped"
}

type Session struct {
ID string
ShortID string
FilePath string
ProjectPath string
ProjectName string
GitBranch string
ModTime time.Time
MsgCount int
FirstPrompt string
Created time.Time
IsWorktree bool
ID string
ShortID string
FilePath string
ProjectPath string
ProjectName string
GitBranch string
ModTime time.Time
MsgCount int
FirstPrompt string
Created time.Time
IsWorktree bool
IsLive bool
IsResponding bool
HasMemory bool
HasTodos bool
Todos []TodoItem
HasTasks bool
HasCrons bool
HasPlan bool
PlanSlug string // first plan slug (kept for compat)
PlanSlugs []string // all distinct plan slugs in order
Tasks []TaskItem
Crons []CronItem
HasMemory bool
HasTodos bool
Todos []TodoItem
HasTasks bool
HasCrons bool
HasPlan bool
PlanSlug string // first plan slug (kept for compat)
PlanSlugs []string // all distinct plan slugs in order
Tasks []TaskItem
Crons []CronItem
TeamName string // e.g. "supports-build"
TeamRole string // "leader", "teammate", ""
TeammateName string // e.g. "build-deploy" (teammate only)
Expand All @@ -61,10 +78,13 @@ type Session struct {
HasCompaction bool
HasSkills bool
HasMCP bool
HasShellJobs bool // background Bash or Monitor invocations present
ShellJobs []ShellJob // populated lazily when HasShellJobs is true

CustomBadges []string // user-created badge tags

TmuxWindowName string // tmux window name (set if pane CWD matches ProjectPath)
TmuxWindowName string // tmux window name (set if pane CWD matches ProjectPath)
IsCurrentWindow bool // session lives in the same tmux window as ccx (pane CWD matches)

// Remote execution
IsRemote bool // virtual remote session
Expand Down Expand Up @@ -97,13 +117,13 @@ type HookInfo struct {
}

type ContentBlock struct {
Type string
Text string
ToolName string
ToolInput string
IsError bool
ID string // tool_use block ID (e.g., "toolu_01...")
Hooks []HookInfo // hooks that ran for this tool_use block
Type string
Text string
ToolName string
ToolInput string
IsError bool
ID string // tool_use block ID (e.g., "toolu_01...")
Hooks []HookInfo // hooks that ran for this tool_use block
TagName string // for system_tag blocks: the XML tag name (e.g., "system-reminder")
ImagePasteID int // for image blocks: the paste ID for cache lookup (0 = not set)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/session/scanner_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore *
sess.HasCrons = true
}
}
if !sess.HasShellJobs {
if bytes.Contains(line, bMonitorTool) || bytes.Contains(line, bMonitorToolS) {
sess.HasShellJobs = true
} else if bytes.Contains(line, bRunInBackground) || bytes.Contains(line, bRunInBackgroundS) {
sess.HasShellJobs = true
}
}

// Team detection (check any line for teamName/agentName)
if sess.TeamName == "" {
Expand Down
123 changes: 123 additions & 0 deletions internal/session/shells.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package session

import (
"encoding/json"
)

type shellInputBash struct {
Command string `json:"command"`
Description string `json:"description"`
RunInBackground bool `json:"run_in_background"`
Timeout json.Number `json:"timeout"`
DangerouslyDisableSandbox bool `json:"dangerouslyDisableSandbox"`
}

type shellInputMonitor struct {
Command string `json:"command"`
Description string `json:"description"`
Persistent bool `json:"persistent"`
TimeoutMS json.Number `json:"timeout_ms"`
DangerouslyDisableSandbox bool `json:"dangerouslyDisableSandbox"`
}

type shellInputResult struct {
ToolUseID string `json:"tool_use_id"`
}

// LoadShellJobsFromEntries scans parsed entries for background Bash and Monitor
// tool invocations. It correlates BashOutput/KillShell calls (which carry a
// tool_use_id) back to the originating shell so we can show how many polls
// happened and whether the shell was explicitly killed.
//
// Entries are assumed to be in chronological order, matching how the JSONL is
// stored on disk.
func LoadShellJobsFromEntries(entries []Entry) []ShellJob {
var jobs []ShellJob
byID := make(map[string]int) // tool_use ID → index in jobs

for _, e := range entries {
for _, b := range e.Content {
if b.Type != "tool_use" {
continue
}
switch b.ToolName {
case "Bash":
var in shellInputBash
if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil {
continue
}
if !in.RunInBackground {
continue
}
timeout, _ := in.Timeout.Int64()
job := ShellJob{
ID: b.ID,
ToolName: "Bash",
Command: in.Command,
Description: in.Description,
TimeoutMS: int(timeout),
DangerouslyDisableSandbox: in.DangerouslyDisableSandbox,
StartedAt: e.Timestamp,
LastEventAt: e.Timestamp,
Status: "running",
}
if b.ID != "" {
byID[b.ID] = len(jobs)
}
jobs = append(jobs, job)

case "Monitor":
var in shellInputMonitor
if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil {
continue
}
timeout, _ := in.TimeoutMS.Int64()
job := ShellJob{
ID: b.ID,
ToolName: "Monitor",
Command: in.Command,
Description: in.Description,
Persistent: in.Persistent,
TimeoutMS: int(timeout),
DangerouslyDisableSandbox: in.DangerouslyDisableSandbox,
StartedAt: e.Timestamp,
LastEventAt: e.Timestamp,
Status: "running",
}
if b.ID != "" {
byID[b.ID] = len(jobs)
}
jobs = append(jobs, job)

case "BashOutput":
var in shellInputResult
if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil {
continue
}
if idx, ok := byID[in.ToolUseID]; ok {
jobs[idx].PollCount++
if !e.Timestamp.IsZero() {
jobs[idx].LastEventAt = e.Timestamp
}
if jobs[idx].Status == "running" {
jobs[idx].Status = "polled"
}
}

case "KillShell":
var in shellInputResult
if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil {
continue
}
if idx, ok := byID[in.ToolUseID]; ok {
if !e.Timestamp.IsZero() {
jobs[idx].LastEventAt = e.Timestamp
}
jobs[idx].Status = "killed"
}
}
}
}

return jobs
}
Loading
Loading