Skip to content
Open
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
17 changes: 15 additions & 2 deletions Sources/ClawdboardLib/SessionStateWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@ public class SessionStateWatcher {
private var dispatchSource: DispatchSourceFileSystemObject?
private var cleanupTimer: DispatchSourceTimer?
private let onChange: ([AgentSession]) -> Void
private let hiddenIDsProvider: () -> Set<String>
private let ioQueue = DispatchQueue(label: "clawdboard.session-watcher", qos: .utility)

public init(sessionsDirectory: String? = nil, onChange: @escaping ([AgentSession]) -> Void) {
public init(
sessionsDirectory: String? = nil,
hiddenIDsProvider: @escaping () -> Set<String> = { VSCodeHiddenSessions.allHiddenSessionIDs() },
onChange: @escaping ([AgentSession]) -> Void
) {
let dir =
sessionsDirectory
?? {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent(".clawdboard/sessions").path
}()
self.sessionsDir = URL(fileURLWithPath: dir)
self.hiddenIDsProvider = hiddenIDsProvider
self.onChange = onChange
}

Expand Down Expand Up @@ -88,7 +94,7 @@ public class SessionStateWatcher {
}

/// Read all .json state files from the sessions directory.
/// Removes state files for processes that are no longer running.
/// Removes state files for processes that are no longer running or hidden in IDE.
public func readAllSessions() -> [AgentSession] {
let fm = FileManager.default
guard
Expand All @@ -98,6 +104,7 @@ public class SessionStateWatcher {
return []
}

let hiddenIDs = hiddenIDsProvider()
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

Expand All @@ -108,6 +115,12 @@ public class SessionStateWatcher {
return nil
}

// Session was deleted in VS Code UI — remove state file
if hiddenIDs.contains(session.sessionId) {
try? fm.removeItem(at: url)
return nil
}
Comment thread
jjeliga marked this conversation as resolved.

// Check if the Claude Code process is still alive
if let pid = session.pid {
if kill(pid_t(pid), 0) != 0 {
Expand Down
113 changes: 113 additions & 0 deletions Sources/ClawdboardLib/VSCodeHiddenSessions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation
import SQLite3

/// Reads hidden session IDs from VS Code-family IDE global state databases.
///
/// When a user deletes a session in VS Code's Claude sidebar (trash icon),
/// the extension adds the session ID to `hiddenSessionIds` in its global state
/// (stored in a SQLite DB). The Claude process often stays alive, so neither
/// SessionEnd hooks nor PID liveness checks clean up the state file.
///
/// This provides a definitive cleanup signal: if VS Code says a session is
/// hidden, its state file can be safely removed.
///
/// Discovers all VS Code variants (Code, Insiders, Cursor, Windsurf, etc.)
/// automatically by scanning Application Support for the standard
/// `User/globalStorage/state.vscdb` layout.
public enum VSCodeHiddenSessions {

/// The key under which the Claude Code extension stores its global state.
/// This is the `publisher.name` identifier from the extension's package.json.
private static let extensionStateKey = "Anthropic.claude-code"

/// Swift equivalent of C's SQLITE_TRANSIENT — tells SQLite to copy the bound value
/// immediately. The C macro `((sqlite3_destructor_type)-1)` can't be auto-imported.
private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

/// Cached result to avoid scanning Application Support on every 3-second poll.
/// Thread safety: these are only accessed from SessionStateWatcher's serial ioQueue.
/// If called from multiple threads, add a lock.
private static var cachedHiddenIDs = Set<String>()
private static var cacheTimestamp: Date = .distantPast
private static let cacheLifetime: TimeInterval = 30.0

/// Returns all session IDs that users have hidden/deleted across all
/// VS Code-family IDEs on this machine. Cached for 30 seconds.
public static func allHiddenSessionIDs() -> Set<String> {
let now = Date()
if now.timeIntervalSince(cacheTimestamp) < cacheLifetime {
return cachedHiddenIDs
}

var result = Set<String>()
for dbPath in discoverStateDBPaths() {
result.formUnion(readHiddenIDs(from: dbPath))
}

cachedHiddenIDs = result
cacheTimestamp = now
debugLog("[IDEHidden] Refreshed: \(result.count) hidden session IDs")
return result
}

/// Discover VS Code-family global state databases by scanning
/// `~/Library/Application Support/*/User/globalStorage/state.vscdb`.
private static func discoverStateDBPaths() -> [String] {
guard
let appSupportURL = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first
else { return [] }

guard
let entries = try? FileManager.default.contentsOfDirectory(
at: appSupportURL,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
)
Comment thread
jjeliga marked this conversation as resolved.
else { return [] }

return entries.compactMap { dir in
let dbURL =
dir
.appendingPathComponent("User")
.appendingPathComponent("globalStorage")
.appendingPathComponent("state.vscdb")
return FileManager.default.fileExists(atPath: dbURL.path) ? dbURL.path : nil
}
}
Comment thread
jjeliga marked this conversation as resolved.

/// Read `hiddenSessionIds` from a single VS Code global state database.
private static func readHiddenIDs(from dbPath: String) -> Set<String> {
var db: OpaquePointer?
guard
sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil)
== SQLITE_OK
else {
debugLog("[IDEHidden] Failed to open DB: \(dbPath)")
return []
}
defer { sqlite3_close(db) }

var stmt: OpaquePointer?
let query = "SELECT value FROM ItemTable WHERE key = ?"
guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }

sqlite3_bind_text(stmt, 1, extensionStateKey, -1, sqliteTransient)

guard sqlite3_step(stmt) == SQLITE_ROW,
let cString = sqlite3_column_text(stmt, 0)
else { return [] }

guard let data = String(cString: cString).data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ids = dict["hiddenSessionIds"] as? [String]
else {
debugLog("[IDEHidden] Failed to parse hiddenSessionIds from: \(dbPath)")
return []
}

return Set(ids)
}
}
2 changes: 1 addition & 1 deletion Sources/ClawdboardLib/Views/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public struct PanelView: View {

private var footer: some View {
HStack {
Text("\(appState.sessions.count) session\(appState.sessions.count == 1 ? "" : "s")")
Text("\(appState.activeSessions.count) session\(appState.activeSessions.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)

Expand Down
2 changes: 1 addition & 1 deletion Sources/ClawdboardLib/Views/SessionsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public struct SessionsTab: View {

/// Sessions grouped alphabetically (stable order).
private var groupedSessions: [(key: String, sessions: [AgentSession])] {
let dict = Dictionary(grouping: appState.sortedSessions) { session in
let dict = Dictionary(grouping: appState.activeSessions) { session in
session.githubRepo ?? session.projectName
}
return
Expand Down