diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index c17c5713f3..3feb22ab36 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ Features/Worktrunk/AgentStatus/AgentHookInstaller.swift, Features/Worktrunk/AgentStatus/AgentStatusModels.swift, Features/Worktrunk/AgentStatus/AgentStatusPaths.swift, + Features/Worktrunk/AgentStatus/CursorAgentDB.swift, Features/Worktrunk/GitHub/GHClient.swift, Features/Worktrunk/GitHub/GitHubModels.swift, Features/Worktrunk/GitHub/GitRefWatcher.swift, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 0f14db895c..9a90a774a2 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1930,6 +1930,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr base.command = "codex resume \(session.id)" case .opencode: base.command = "opencode --session \(session.id)" + case .agent: + base.command = "agent --resume \(session.id)" } if WorktrunkPreferences.worktreeTabsEnabled { diff --git a/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift b/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift index bb15837885..78c51a4360 100644 --- a/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift +++ b/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift @@ -1,9 +1,10 @@ import Foundation enum AgentHookInstaller { - private static let notifyScriptMarker = "# Ghostree agent notification hook v7" + private static let notifyScriptMarker = "# Ghostree agent notification hook v8" private static let claudeSettingsMarker = "\"_v\":3" - private static let wrapperMarker = "# Ghostree agent wrapper v3" + private static let wrapperMarker = "# Ghostree agent wrapper v4" + private static let cursorAgentHooksMarker = "ghostree-notify" static func ensureInstalled() { if ProcessInfo.processInfo.environment["GHOSTREE_DISABLE_AGENT_HOOKS"] == "1" { @@ -51,6 +52,13 @@ enum AgentHookInstaller { marker: wrapperMarker, content: buildCodexWrapper() ) + ensureFile( + url: AgentStatusPaths.cursorAgentWrapperPath, + mode: 0o755, + marker: wrapperMarker, + content: buildCursorAgentWrapper() + ) + ensureCursorAgentGlobalHooks(notifyPath: AgentStatusPaths.notifyHookPath.path) ensureFile( url: AgentStatusPaths.opencodeGlobalPluginPath, @@ -129,6 +137,9 @@ enum AgentHookInstaller { [ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" [ "$EVENT_TYPE" = "PermissionResponse" ] && EVENT_TYPE="Start" [ "$EVENT_TYPE" = "SessionEnd" ] && EVENT_TYPE="SessionEnd" + [ "$EVENT_TYPE" = "stop" ] && EVENT_TYPE="Stop" + [ "$EVENT_TYPE" = "pre_tool_use" ] && EVENT_TYPE="Start" + [ "$EVENT_TYPE" = "post_tool_use" ] && EVENT_TYPE="Start" if [ -z "$EVENT_TYPE" ]; then TS="$(perl -MTime::HiRes=time -MPOSIX=strftime -e '$t=time; $s=int($t); $ms=int(($t-$s)*1000); print strftime(\"%Y-%m-%dT%H:%M:%S\", gmtime($s)).sprintf(\".%03dZ\", $ms);')" CWD="$(pwd -P 2>/dev/null || pwd)" @@ -149,11 +160,18 @@ enum AgentHookInstaller { if [ -z "$JSON_CWD" ]; then JSON_CWD=$(echo "$INPUT" | grep -oE '"worktree"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') fi + if [ -z "$JSON_CWD" ]; then + JSON_CWD=$(echo "$INPUT" | grep -oE '"workspace_roots"[[:space:]]*:[[:space:]]*\\["[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') + fi if [ "$JSON_CWD" = "/" ]; then JSON_CWD="" fi if [ -n "$JSON_CWD" ]; then CWD="$JSON_CWD" + elif [ -n "$CURSOR_PROJECT_DIR" ]; then + CWD="$CURSOR_PROJECT_DIR" + elif [ -n "$CLAUDE_PROJECT_DIR" ]; then + CWD="$CLAUDE_PROJECT_DIR" else CWD="$(pwd -P 2>/dev/null || pwd)" fi @@ -295,6 +313,102 @@ enum AgentHookInstaller { """ } + private static func buildCursorAgentWrapper() -> String { + let binDir = AgentStatusPaths.binDir.path + let eventsDir = AgentStatusPaths.eventsCacheDir.path + return """ + #!/bin/bash + \(wrapperMarker) + # Wrapper for Cursor Agent: emits lifecycle events. + # Hook configuration is managed via ~/.cursor/hooks.json. + + \(pathAugmentSnippet()) + + find_real_binary() { + local name="$1" + local IFS=: + for dir in $PATH; do + [ -z "$dir" ] && continue + dir="${dir%/}" + if [ "$dir" = "\(binDir)" ]; then + continue + fi + if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then + printf "%s\\n" "$dir/$name" + return 0 + fi + done + return 1 + } + + REAL_BIN="$(find_real_binary "agent")" + if [ -z "$REAL_BIN" ]; then + REAL_BIN="$(find_real_binary "cursor-agent")" + fi + if [ -z "$REAL_BIN" ]; then + echo "Ghostree: agent (Cursor Agent) not found in PATH. Install it and ensure it is on PATH, then retry." >&2 + exit 127 + fi + + # Emit synthetic Start event for Cursor Agent + printf '{\"timestamp\":\"%s\",\"eventType\":\"Start\",\"cwd\":\"%s\"}\\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ + "$(pwd -P 2>/dev/null || pwd)" \ + >> "${GHOSTREE_AGENT_EVENTS_DIR:-\(eventsDir)}/agent-events.jsonl" 2>/dev/null + + exec "$REAL_BIN" "$@" + """ + } + + /// Merges the Ghostree stop hook into ~/.cursor/hooks.json without clobbering + /// any existing user hooks. Idempotent: checks for the marker command before writing. + private static func ensureCursorAgentGlobalHooks(notifyPath: String) { + let url = AgentStatusPaths.cursorAgentGlobalHooksPath + let escapedNotifyPath = notifyPath.replacingOccurrences(of: "'", with: "'\\''") + let ghostreeCommand = "bash '\(escapedNotifyPath)'" + + // Read existing file if it exists + var root: [String: Any] = [:] + if let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + root = json + } + + // Already installed? + if let existing = try? String(contentsOf: url, encoding: .utf8), + existing.contains(cursorAgentHooksMarker) { + return + } + + root["version"] = 1 + + var hooks = root["hooks"] as? [String: Any] ?? [:] + var stopHooks = hooks["stop"] as? [[String: Any]] ?? [] + + // Remove any stale Ghostree entries + stopHooks.removeAll { entry in + guard let cmd = entry["command"] as? String else { return false } + return cmd.contains("ghostree") || cmd.contains("Ghostree") || cmd.contains(cursorAgentHooksMarker) + } + + // Add the Ghostree hook (tagged so we can find it later) + stopHooks.append(["command": ghostreeCommand]) + hooks["stop"] = stopHooks + root["hooks"] = hooks + + // Ensure ~/.cursor directory exists + let parentDir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + + guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]), + var jsonString = String(data: data, encoding: .utf8) else { + return + } + + jsonString += "\n" + try? jsonString.write(to: url, atomically: true, encoding: .utf8) + } + private static func buildOpenCodePlugin() -> String { let marker = AgentStatusPaths.opencodePluginMarker return """ diff --git a/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift b/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift index ccd52fad1b..099bab37ec 100644 --- a/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift +++ b/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift @@ -46,6 +46,16 @@ enum AgentStatusPaths { binDir.appendingPathComponent("codex") } + static var cursorAgentWrapperPath: URL { + binDir.appendingPathComponent("agent") + } + + static var cursorAgentGlobalHooksPath: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + .appendingPathComponent("hooks.json", isDirectory: false) + } + static var opencodePluginMarker: String { "// Ghostree opencode plugin v5" } /** @see https://opencode.ai/docs/plugins */ diff --git a/macos/Sources/Features/Worktrunk/AgentStatus/CursorAgentDB.swift b/macos/Sources/Features/Worktrunk/AgentStatus/CursorAgentDB.swift new file mode 100644 index 0000000000..9e94da6338 --- /dev/null +++ b/macos/Sources/Features/Worktrunk/AgentStatus/CursorAgentDB.swift @@ -0,0 +1,80 @@ +import CryptoKit +import Foundation +import SQLite3 + +/// Lightweight read-only accessor for Cursor Agent chat store.db files. +/// The DB has two tables: `meta` (key TEXT, value TEXT) and `blobs` (id TEXT, data BLOB). +/// The meta row with key "0" holds hex-encoded JSON with session metadata. +final class CursorAgentDB { + struct Meta { + var agentId: String? + var name: String? + var createdAt: Double? + var lastUsedModel: String? + } + + private var db: OpaquePointer? + + init?(path: String) { + var handle: OpaquePointer? + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX + guard sqlite3_open_v2(path, &handle, flags, nil) == SQLITE_OK else { + if let handle { sqlite3_close(handle) } + return nil + } + self.db = handle + } + + func close() { + if let db { + sqlite3_close(db) + self.db = nil + } + } + + deinit { + close() + } + + func readMeta() -> Meta? { + guard let db else { return nil } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key = '0' LIMIT 1", -1, &stmt, nil) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(stmt) } + + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + guard let cstr = sqlite3_column_text(stmt, 0) else { return nil } + let hexString = String(cString: cstr) + + guard let jsonData = dataFromHex(hexString) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return nil } + + var meta = Meta() + meta.agentId = json["agentId"] as? String + meta.name = json["name"] as? String + meta.createdAt = json["createdAt"] as? Double + meta.lastUsedModel = json["lastUsedModel"] as? String + return meta + } + + /// Cursor Agent uses MD5(workspace_path) as the project directory hash. + static func projectHash(for workspacePath: String) -> String { + let digest = Insecure.MD5.hash(data: Data(workspacePath.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func dataFromHex(_ hex: String) -> Data? { + let chars = Array(hex) + guard chars.count % 2 == 0 else { return nil } + var data = Data(capacity: chars.count / 2) + var i = 0 + while i < chars.count { + guard let byte = UInt8(String(chars[i..//store.db) + // Project hash = MD5 of the workspace path, so we compute hashes for all + // known worktree paths and only look at matching directories. + var seenAgentPaths = Set() + let cursorChatsDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor/chats") + + if FileManager.default.fileExists(atPath: cursorChatsDir.path) { + var hashToWorktree: [String: String] = [:] + for path in validWorktreePaths { + let hash = CursorAgentDB.projectHash(for: path) + hashToWorktree[hash] = path + } + + for (projectHash, worktreePath) in hashToWorktree { + let projectDir = cursorChatsDir.appendingPathComponent(projectHash, isDirectory: true) + guard FileManager.default.fileExists(atPath: projectDir.path) else { continue } + + let sessionDirs = (try? FileManager.default.contentsOfDirectory( + at: projectDir, + includingPropertiesForKeys: [.isDirectoryKey] + ))?.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } ?? [] + + for sessionDir in sessionDirs { + let dbURL = sessionDir.appendingPathComponent("store.db") + guard FileManager.default.fileExists(atPath: dbURL.path) else { continue } + let sessionId = sessionDir.lastPathComponent + let indexKey = dbURL.path + seenAgentPaths.insert(indexKey) + + guard let attrs = fileAttributes(for: dbURL) else { continue } + let cached = index.cursorAgent[indexKey] + + if let cached, + cached.dbMtime == attrs.mtime, + cached.dbSize == attrs.size { + let session = AISession( + id: cached.sessionId, + source: .agent, + worktreePath: worktreePath, + cwd: worktreePath, + timestamp: cached.timestamp, + snippet: cached.chatName, + sourcePath: dbURL.path, + messageCount: 0 + ) + noteSession(session, worktreePath: worktreePath) + if cached.worktreePath != worktreePath { + var updated = cached + updated.worktreePath = worktreePath + index.cursorAgent[indexKey] = updated + } + continue + } + + if let parsed = parseCursorAgentSession(dbURL: dbURL, sessionId: sessionId) { + var session = parsed + session.worktreePath = worktreePath + session.cwd = worktreePath + noteSession(session, worktreePath: worktreePath) + index.cursorAgent[indexKey] = CursorAgentIndexEntry( + sessionId: sessionId, + projectHash: projectHash, + chatName: parsed.snippet, + worktreePath: worktreePath, + timestamp: parsed.timestamp, + dbMtime: attrs.mtime, + dbSize: attrs.size + ) + } else { + index.cursorAgent[indexKey] = nil + } + } + } + } else { + index.cursorAgent = [:] + } + + index.cursorAgent = index.cursorAgent.filter { seenAgentPaths.contains($0.key) } + // Final sort and single publish sortDirtySessions() @@ -1974,6 +2070,34 @@ final class WorktrunkStore: ObservableObject { ) } + // MARK: - Cursor Agent Sessions + + private func parseCursorAgentSession(dbURL: URL, sessionId: String) -> AISession? { + guard let db = try? CursorAgentDB(path: dbURL.path) else { return nil } + defer { db.close() } + + guard let meta = db.readMeta() else { return nil } + let createdAt = meta.createdAt.map { Date(timeIntervalSince1970: $0 / 1000.0) } + + let dbModDate: Date? = { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: dbURL.path), + let mdate = attrs[.modificationDate] as? Date else { return nil } + return mdate + }() + let timestamp = dbModDate ?? createdAt ?? Date.distantPast + + return AISession( + id: sessionId, + source: .agent, + worktreePath: "", + cwd: "", + timestamp: timestamp, + snippet: meta.name, + sourcePath: dbURL.path, + messageCount: 0 + ) + } + // MARK: - Session Helpers private func findMatchingWorktree(_ cwd: String) -> String? { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 66d246d3c2..0781e3d52f 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -209,6 +209,7 @@ function __ghostty_precmd() { claude() { command "$GHOSTREE_AGENT_BIN_DIR/claude" "$@"; } codex() { command "$GHOSTREE_AGENT_BIN_DIR/codex" "$@"; } + agent() { command "$GHOSTREE_AGENT_BIN_DIR/agent" "$@"; } fi if test "$_ghostty_executing" != "0"; then diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 8469449c04..b6da0a7e7b 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -110,6 +110,9 @@ _ghostty_deferred_init() { if (( $+functions[codex] == 0 )); then codex() { command "$GHOSTREE_AGENT_BIN_DIR/codex" "$@"; } fi + if (( $+functions[agent] == 0 )); then + agent() { command "$GHOSTREE_AGENT_BIN_DIR/agent" "$@"; } + fi fi # Don't write OSC 133 D when our precmd handler is invoked from zle.