Skip to content
Draft
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
1 change: 1 addition & 0 deletions macos/Ghostty.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions macos/Sources/Features/Terminal/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
118 changes: 116 additions & 2 deletions macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift
Original file line number Diff line number Diff line change
@@ -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" {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)"
Expand All @@ -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
Expand Down Expand Up @@ -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 """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
80 changes: 80 additions & 0 deletions macos/Sources/Features/Worktrunk/AgentStatus/CursorAgentDB.swift
Original file line number Diff line number Diff line change
@@ -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..<i+2]), radix: 16) else { return nil }
data.append(byte)
i += 2
}
return data
}
}
6 changes: 6 additions & 0 deletions macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
case claude
case codex
case opencode
case agent

var id: String { rawValue }

Expand All @@ -13,6 +14,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
case .claude: return "Claude Code"
case .codex: return "Codex"
case .opencode: return "OpenCode"
case .agent: return "Cursor Agent"
}
}

Expand All @@ -21,6 +23,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
case .claude: return "claude"
case .codex: return "codex"
case .opencode: return "opencode"
case .agent: return "agent"
}
}

Expand Down Expand Up @@ -76,6 +79,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
case claude
case codex
case opencode
case agent

var id: String { rawValue }

Expand All @@ -85,6 +89,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
case .claude: return "Claude Code"
case .codex: return "Codex"
case .opencode: return "OpenCode"
case .agent: return "Cursor Agent"
}
}

Expand All @@ -94,6 +99,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
case .claude: return .claude
case .codex: return .codex
case .opencode: return .opencode
case .agent: return .agent
}
}

Expand Down
Loading