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
72 changes: 21 additions & 51 deletions Sources/ClawdboardLib/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,18 @@ public class AppState {
rebuildSessions()
remoteWatcher?.deleteSession(sessionId, on: host)
} else {
let fm = FileManager.default
let file = sessionsDir.appendingPathComponent("\(sessionId).json")
try? FileManager.default.removeItem(at: file)
try? fm.removeItem(at: file)
// Clean up agent fact files and lock files
if let contents = try? fm.contentsOfDirectory(
at: sessionsDir, includingPropertiesForKeys: nil)
{
for url in contents
where url.lastPathComponent.hasPrefix("\(sessionId).agent.") {
try? fm.removeItem(at: url)
}
}
}
}

Expand Down Expand Up @@ -217,7 +227,7 @@ public class AppState {
let targets = sessions.compactMap { session -> DiffStatsProvider.DiffStatsTarget? in
guard !session.cwd.isEmpty,
session.remoteHost == nil,
session.displayStatus != .abandoned
session.status != .abandoned
else { return nil }
return DiffStatsProvider.DiffStatsTarget(sessionId: session.sessionId, cwd: session.cwd)
}
Expand Down Expand Up @@ -376,7 +386,7 @@ public class AppState {
private func updateApprovalTracking(_ all: [AgentSession]) -> Bool {
var shouldPlayAlert = false
for session in all {
let display = session.displayStatus
let display = session.status
let previous = previousStatuses[session.sessionId]
if display == .needsApproval && previous != nil && previous != .needsApproval {
shouldPlayAlert = true
Expand All @@ -391,39 +401,11 @@ public class AppState {

// MARK: - Session Processing

/// Apply ghost filtering, debounce, staleness, and abandoned logic to a session.
private func processSession(_ session: AgentSession, now: Date) -> AgentSession? {
var s = session

// Ghost session filter: no model means the session never produced output
if s.model == nil {
if let started = s.startedAt, let updated = s.updatedAt {
let neverUpdated = abs(updated.timeIntervalSince(started)) < 1.0
if neverUpdated, now.timeIntervalSince(started) > 30 {
return nil
}
if now.timeIntervalSince(updated) > 300 {
return nil
}
}
guard let started = s.startedAt, now.timeIntervalSince(started) < 60 else {
return nil
}
}
private let sessionProcessor = SessionProcessor()

if let updatedAt = s.updatedAt {
let age = now.timeIntervalSince(updatedAt)
if s.status == .pendingWaiting, age >= 1.5 {
s.status = .waiting
}
if s.status == .working, age >= 15.0 {
s.status = .waiting
}
if s.status == .waiting, age >= 600.0 {
s.status = .abandoned
}
}
return s
/// Process a raw session into a display-ready session via SessionProcessor.
private func processSession(_ session: AgentSession, now: Date) -> AgentSession? {
sessionProcessor.process(session, now: now)
}

// MARK: - Computed Properties
Expand All @@ -438,20 +420,20 @@ public class AppState {
/// Sessions that are actively doing something (working or waiting for input)
public var activeSessions: [AgentSession] {
sortedSessions.filter {
$0.displayStatus != .unknown && $0.displayStatus != .abandoned
$0.status != .unknown && $0.status != .abandoned
}
}

public var needsApprovalCount: Int {
sessions.count { $0.displayStatus == .needsApproval }
sessions.count { $0.status == .needsApproval }
}

public var waitingCount: Int {
sessions.count { $0.displayStatus == .waiting }
sessions.count { $0.status == .waiting }
}

public var workingCount: Int {
sessions.count { $0.displayStatus == .working }
sessions.count { $0.status == .working }
}

// MARK: - Actions
Expand Down Expand Up @@ -901,15 +883,3 @@ public class AppState {
}
}

// MARK: - Display Status Helper

extension AgentSession {
/// The status to show in the UI, accounting for debounce
/// (pending_waiting shows as "working" until debounced to "waiting")
public var displayStatus: AgentStatus {
switch status {
case .pendingWaiting: return .working
default: return status
}
}
}
17 changes: 3 additions & 14 deletions Sources/ClawdboardLib/HookManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ public class HookManager {

/// All hook events we register for. Claude Code requires each as a separate key.
private static let hookEvents: [String] = [
"SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop",
"UserPromptSubmit", "SessionEnd", "SubagentStart", "SubagentStop",
"SessionStart", "PreToolUse", "PostToolUse", "PostToolUseFailure",
"PermissionRequest", "Stop", "StopFailure", "UserPromptSubmit",
"SessionEnd", "SubagentStart", "SubagentStop",
]

/// Check if all expected hooks are installed (via plugin or direct settings)
Expand Down Expand Up @@ -112,18 +113,6 @@ public class HookManager {
hooks[event] = eventHooks
}

// Notification hooks use specific matchers to distinguish type
var notifHooks = removeClawdboard(hooks["Notification"] as? [[String: Any]] ?? [])
for matcher in ["idle_prompt", "permission_prompt"] {
let entry: [String: Any] = [
"type": "command",
"command": hookCommand + " \(matcher)",
"timeout": 10,
]
notifHooks.append(["matcher": matcher, "hooks": [entry]])
}
hooks["Notification"] = notifHooks

settings["hooks"] = hooks

let data = try JSONSerialization.data(
Expand Down
90 changes: 81 additions & 9 deletions Sources/ClawdboardLib/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ public enum RemoteHookStatus: String, Codable {

public enum AgentStatus: String, Codable, CaseIterable {
case working
case pendingWaiting = "pending_waiting"
case needsApproval = "needs_approval"
case waiting
case unknown
Expand All @@ -161,17 +160,15 @@ public enum AgentStatus: String, Codable, CaseIterable {
switch self {
case .needsApproval: return 0
case .waiting: return 1
case .pendingWaiting: return 2
case .working: return 3
case .unknown: return 4
case .abandoned: return 5
case .working: return 2
case .unknown: return 3
case .abandoned: return 4
}
}

public var displayLabel: String {
switch self {
case .working: return "Working"
case .pendingWaiting: return "Working" // Show as working until debounce completes
case .needsApproval: return "Approve"
case .waiting: return "Your turn"
case .unknown: return "Unknown"
Expand All @@ -183,6 +180,7 @@ public enum AgentStatus: String, Codable, CaseIterable {
// MARK: - Subagent

/// A subagent spawned by a parent session via the Agent tool.
/// Simplified: status tracking is via active_tools, not per-subagent.
public struct Subagent: Codable, Equatable, Identifiable {
public var id: String { agentId }

Expand All @@ -197,6 +195,25 @@ public struct Subagent: Codable, Equatable, Identifiable {
}
}

// MARK: - Active Tool

/// A single tracked tool call in active_tools.
public struct ActiveTool: Codable, Equatable {
public let status: AgentStatus?
public let toolName: String?
public let agentId: String?
public let command: String?
public let addedAt: Date?

enum CodingKeys: String, CodingKey {
case status
case toolName = "tool_name"
case agentId = "agent_id"
case command
case addedAt = "added_at"
}
}

// MARK: - Context Snapshot

/// A timestamped context usage snapshot for sparkline rendering.
Expand Down Expand Up @@ -229,15 +246,16 @@ public struct PRInfo: Codable, Equatable {
// MARK: - Agent Session

/// Represents a Claude Code agent session.
/// For hook-tracked sessions, this maps directly to the state file JSON.
/// Session metadata comes from {session_id}.json, per-agent data from .agent.*.json files.
/// For fallback-discovered sessions, only a subset of fields are populated.
public struct AgentSession: Identifiable, Codable, Equatable {
public var id: String { sessionId }

public let sessionId: String
public let cwd: String
public var projectName: String
public var status: AgentStatus
/// Session-level status — derived by SessionProcessor from agent facts, not from JSON.
public var status: AgentStatus = .unknown
public var model: String?
public var gitBranch: String?
public var slug: String?
Expand Down Expand Up @@ -302,6 +320,15 @@ public struct AgentSession: Identifiable, Codable, Equatable {
/// Terminal tab title set via ANSI escape, used for JetBrains AX tab focus
public var terminalTabTitle: String?

/// Active tool calls tracked by tool_use_id. Python hook manages this dict.
public var activeTools: [String: ActiveTool]?

/// Whether the model is generating (between UserPromptSubmit and Stop)
public var agentWorking: Bool?

/// Path to the session's transcript JSONL file
public var transcriptPath: String?

enum CodingKeys: String, CodingKey {
case sessionId = "session_id"
case cwd
Expand Down Expand Up @@ -332,6 +359,9 @@ public struct AgentSession: Identifiable, Codable, Equatable {
case approvalTimestamps = "approval_timestamps"
case prInfo = "pr_info"
case terminalTabTitle = "terminal_tab_title"
case activeTools = "active_tools"
case agentWorking = "agent_working"
case transcriptPath = "transcript_path"
}

public init(
Expand Down Expand Up @@ -363,7 +393,10 @@ public struct AgentSession: Identifiable, Codable, Equatable {
contextSnapshots: [ContextSnapshot]? = nil,
approvalTimestamps: [Date]? = nil,
prInfo: PRInfo? = nil,
terminalTabTitle: String? = nil
terminalTabTitle: String? = nil,
activeTools: [String: ActiveTool]? = nil,
agentWorking: Bool? = nil,
transcriptPath: String? = nil
) {
self.sessionId = sessionId
self.cwd = cwd
Expand Down Expand Up @@ -394,6 +427,45 @@ public struct AgentSession: Identifiable, Codable, Equatable {
self.approvalTimestamps = approvalTimestamps
self.prInfo = prInfo
self.terminalTabTitle = terminalTabTitle
self.activeTools = activeTools
self.agentWorking = agentWorking
self.transcriptPath = transcriptPath
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
sessionId = try c.decode(String.self, forKey: .sessionId)
cwd = try c.decode(String.self, forKey: .cwd)
projectName = try c.decodeIfPresent(String.self, forKey: .projectName) ?? ""
status = try c.decodeIfPresent(AgentStatus.self, forKey: .status) ?? .unknown
model = try c.decodeIfPresent(String.self, forKey: .model)
gitBranch = try c.decodeIfPresent(String.self, forKey: .gitBranch)
slug = try c.decodeIfPresent(String.self, forKey: .slug)
contextPct = try c.decodeIfPresent(Double.self, forKey: .contextPct)
startedAt = try c.decodeIfPresent(Date.self, forKey: .startedAt)
updatedAt = try c.decodeIfPresent(Date.self, forKey: .updatedAt)
subagents = try c.decodeIfPresent([Subagent].self, forKey: .subagents)
pid = try c.decodeIfPresent(Int.self, forKey: .pid)
isHookTracked = try c.decodeIfPresent(Bool.self, forKey: .isHookTracked) ?? false
remoteHost = try c.decodeIfPresent(String.self, forKey: .remoteHost)
githubRepo = try c.decodeIfPresent(String.self, forKey: .githubRepo)
iterm2SessionId = try c.decodeIfPresent(String.self, forKey: .iterm2SessionId)
title = try c.decodeIfPresent(String.self, forKey: .title)
firstPrompt = try c.decodeIfPresent(String.self, forKey: .firstPrompt)
startSha = try c.decodeIfPresent(String.self, forKey: .startSha)
headSha = try c.decodeIfPresent(String.self, forKey: .headSha)
commitCount = try c.decodeIfPresent(Int.self, forKey: .commitCount)
unpushedCount = try c.decodeIfPresent(Int.self, forKey: .unpushedCount)
gitDirty = try c.decodeIfPresent(Bool.self, forKey: .gitDirty)
additions = try c.decodeIfPresent(Int.self, forKey: .additions)
deletions = try c.decodeIfPresent(Int.self, forKey: .deletions)
contextSnapshots = try c.decodeIfPresent([ContextSnapshot].self, forKey: .contextSnapshots)
approvalTimestamps = try c.decodeIfPresent([Date].self, forKey: .approvalTimestamps)
prInfo = try c.decodeIfPresent(PRInfo.self, forKey: .prInfo)
terminalTabTitle = try c.decodeIfPresent(String.self, forKey: .terminalTabTitle)
activeTools = try c.decodeIfPresent([String: ActiveTool].self, forKey: .activeTools)
agentWorking = try c.decodeIfPresent(Bool.self, forKey: .agentWorking)
transcriptPath = try c.decodeIfPresent(String.self, forKey: .transcriptPath)
}

/// Display title: AI-generated slug title, or generic fallback
Expand Down
Loading