Skip to content

Commit 0014c9e

Browse files
Add Cursor Agent integration
Integrate the Cursor Agent CLI (`agent`) into Ghostree, following the same patterns used for Claude Code, Codex and OpenCode: - Agent wrapper in the hooks bin dir that resolves the real `agent` binary, emits a synthetic Start lifecycle event and execs through - Stop hook installed into ~/.cursor/hooks.json (merged with any existing user hooks) so the notify script fires on agent stop - SessionSource.agent and session discovery from ~/.cursor/chats/ (reads SQLite store.db metadata via a lightweight CursorAgentDB helper) - Shell integration aliases for both zsh and bash - WorktrunkAgent / WorktrunkDefaultAction entries for the sidebar - Resume support via `agent --resume <id>` Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent db07658 commit 0014c9e

9 files changed

Lines changed: 332 additions & 1 deletion

File tree

macos/Ghostty.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
Features/Worktrunk/AgentStatus/AgentHookInstaller.swift,
152152
Features/Worktrunk/AgentStatus/AgentStatusModels.swift,
153153
Features/Worktrunk/AgentStatus/AgentStatusPaths.swift,
154+
Features/Worktrunk/AgentStatus/CursorAgentDB.swift,
154155
Features/Worktrunk/GitHub/GHClient.swift,
155156
Features/Worktrunk/GitHub/GitHubModels.swift,
156157
Features/Worktrunk/GitHub/GitRefWatcher.swift,

macos/Sources/Features/Terminal/TerminalController.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
18721872
base.command = "codex resume \(session.id)"
18731873
case .opencode:
18741874
base.command = "opencode --session \(session.id)"
1875+
case .agent:
1876+
base.command = "agent --resume \(session.id)"
18751877
}
18761878

18771879
if WorktrunkPreferences.worktreeTabsEnabled {

macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import Foundation
33
enum AgentHookInstaller {
44
private static let notifyScriptMarker = "# Ghostree agent notification hook v7"
55
private static let claudeSettingsMarker = "\"_v\":3"
6-
private static let wrapperMarker = "# Ghostree agent wrapper v3"
6+
private static let wrapperMarker = "# Ghostree agent wrapper v4"
7+
private static let cursorAgentHooksMarker = "ghostree-notify"
78

89
static func ensureInstalled() {
910
if ProcessInfo.processInfo.environment["GHOSTREE_DISABLE_AGENT_HOOKS"] == "1" {
@@ -51,6 +52,13 @@ enum AgentHookInstaller {
5152
marker: wrapperMarker,
5253
content: buildCodexWrapper()
5354
)
55+
ensureFile(
56+
url: AgentStatusPaths.cursorAgentWrapperPath,
57+
mode: 0o755,
58+
marker: wrapperMarker,
59+
content: buildCursorAgentWrapper()
60+
)
61+
ensureCursorAgentGlobalHooks(notifyPath: AgentStatusPaths.notifyHookPath.path)
5462

5563
ensureFile(
5664
url: AgentStatusPaths.opencodeGlobalPluginPath,
@@ -295,6 +303,102 @@ enum AgentHookInstaller {
295303
"""
296304
}
297305

306+
private static func buildCursorAgentWrapper() -> String {
307+
let binDir = AgentStatusPaths.binDir.path
308+
let eventsDir = AgentStatusPaths.eventsCacheDir.path
309+
return """
310+
#!/bin/bash
311+
\(wrapperMarker)
312+
# Wrapper for Cursor Agent: emits lifecycle events.
313+
# Hook configuration is managed via ~/.cursor/hooks.json.
314+
315+
\(pathAugmentSnippet())
316+
317+
find_real_binary() {
318+
local name="$1"
319+
local IFS=:
320+
for dir in $PATH; do
321+
[ -z "$dir" ] && continue
322+
dir="${dir%/}"
323+
if [ "$dir" = "\(binDir)" ]; then
324+
continue
325+
fi
326+
if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then
327+
printf "%s\\n" "$dir/$name"
328+
return 0
329+
fi
330+
done
331+
return 1
332+
}
333+
334+
REAL_BIN="$(find_real_binary "agent")"
335+
if [ -z "$REAL_BIN" ]; then
336+
REAL_BIN="$(find_real_binary "cursor-agent")"
337+
fi
338+
if [ -z "$REAL_BIN" ]; then
339+
echo "Ghostree: agent (Cursor Agent) not found in PATH. Install it and ensure it is on PATH, then retry." >&2
340+
exit 127
341+
fi
342+
343+
# Emit synthetic Start event for Cursor Agent
344+
printf '{\"timestamp\":\"%s\",\"eventType\":\"Start\",\"cwd\":\"%s\"}\\n' \
345+
"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
346+
"$(pwd -P 2>/dev/null || pwd)" \
347+
>> "${GHOSTREE_AGENT_EVENTS_DIR:-\(eventsDir)}/agent-events.jsonl" 2>/dev/null
348+
349+
exec "$REAL_BIN" "$@"
350+
"""
351+
}
352+
353+
/// Merges the Ghostree stop hook into ~/.cursor/hooks.json without clobbering
354+
/// any existing user hooks. Idempotent: checks for the marker command before writing.
355+
private static func ensureCursorAgentGlobalHooks(notifyPath: String) {
356+
let url = AgentStatusPaths.cursorAgentGlobalHooksPath
357+
let escapedNotifyPath = notifyPath.replacingOccurrences(of: "'", with: "'\\''")
358+
let ghostreeCommand = "bash '\(escapedNotifyPath)'"
359+
360+
// Read existing file if it exists
361+
var root: [String: Any] = [:]
362+
if let data = try? Data(contentsOf: url),
363+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
364+
root = json
365+
}
366+
367+
// Already installed?
368+
if let existing = try? String(contentsOf: url, encoding: .utf8),
369+
existing.contains(cursorAgentHooksMarker) {
370+
return
371+
}
372+
373+
root["version"] = 1
374+
375+
var hooks = root["hooks"] as? [String: Any] ?? [:]
376+
var stopHooks = hooks["stop"] as? [[String: Any]] ?? []
377+
378+
// Remove any stale Ghostree entries
379+
stopHooks.removeAll { entry in
380+
guard let cmd = entry["command"] as? String else { return false }
381+
return cmd.contains("ghostree") || cmd.contains("Ghostree") || cmd.contains(cursorAgentHooksMarker)
382+
}
383+
384+
// Add the Ghostree hook (tagged so we can find it later)
385+
stopHooks.append(["command": ghostreeCommand])
386+
hooks["stop"] = stopHooks
387+
root["hooks"] = hooks
388+
389+
// Ensure ~/.cursor directory exists
390+
let parentDir = url.deletingLastPathComponent()
391+
try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
392+
393+
guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]),
394+
var jsonString = String(data: data, encoding: .utf8) else {
395+
return
396+
}
397+
398+
jsonString += "\n"
399+
try? jsonString.write(to: url, atomically: true, encoding: .utf8)
400+
}
401+
298402
private static func buildOpenCodePlugin() -> String {
299403
let marker = AgentStatusPaths.opencodePluginMarker
300404
return """

macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ enum AgentStatusPaths {
4646
binDir.appendingPathComponent("codex")
4747
}
4848

49+
static var cursorAgentWrapperPath: URL {
50+
binDir.appendingPathComponent("agent")
51+
}
52+
53+
static var cursorAgentGlobalHooksPath: URL {
54+
FileManager.default.homeDirectoryForCurrentUser
55+
.appendingPathComponent(".cursor", isDirectory: true)
56+
.appendingPathComponent("hooks.json", isDirectory: false)
57+
}
58+
4959
static var opencodePluginMarker: String { "// Ghostree opencode plugin v5" }
5060

5161
/** @see https://opencode.ai/docs/plugins */
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import CryptoKit
2+
import Foundation
3+
import SQLite3
4+
5+
/// Lightweight read-only accessor for Cursor Agent chat store.db files.
6+
/// The DB has two tables: `meta` (key TEXT, value TEXT) and `blobs` (id TEXT, data BLOB).
7+
/// The meta row with key "0" holds hex-encoded JSON with session metadata.
8+
final class CursorAgentDB {
9+
struct Meta {
10+
var agentId: String?
11+
var name: String?
12+
var createdAt: Double?
13+
var lastUsedModel: String?
14+
}
15+
16+
private var db: OpaquePointer?
17+
18+
init?(path: String) {
19+
var handle: OpaquePointer?
20+
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
21+
guard sqlite3_open_v2(path, &handle, flags, nil) == SQLITE_OK else {
22+
if let handle { sqlite3_close(handle) }
23+
return nil
24+
}
25+
self.db = handle
26+
}
27+
28+
func close() {
29+
if let db {
30+
sqlite3_close(db)
31+
self.db = nil
32+
}
33+
}
34+
35+
deinit {
36+
close()
37+
}
38+
39+
func readMeta() -> Meta? {
40+
guard let db else { return nil }
41+
var stmt: OpaquePointer?
42+
guard sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key = '0' LIMIT 1", -1, &stmt, nil) == SQLITE_OK else {
43+
return nil
44+
}
45+
defer { sqlite3_finalize(stmt) }
46+
47+
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
48+
guard let cstr = sqlite3_column_text(stmt, 0) else { return nil }
49+
let hexString = String(cString: cstr)
50+
51+
guard let jsonData = dataFromHex(hexString) else { return nil }
52+
guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return nil }
53+
54+
var meta = Meta()
55+
meta.agentId = json["agentId"] as? String
56+
meta.name = json["name"] as? String
57+
meta.createdAt = json["createdAt"] as? Double
58+
meta.lastUsedModel = json["lastUsedModel"] as? String
59+
return meta
60+
}
61+
62+
/// Cursor Agent uses MD5(workspace_path) as the project directory hash.
63+
static func projectHash(for workspacePath: String) -> String {
64+
let digest = Insecure.MD5.hash(data: Data(workspacePath.utf8))
65+
return digest.map { String(format: "%02x", $0) }.joined()
66+
}
67+
68+
private func dataFromHex(_ hex: String) -> Data? {
69+
let chars = Array(hex)
70+
guard chars.count % 2 == 0 else { return nil }
71+
var data = Data(capacity: chars.count / 2)
72+
var i = 0
73+
while i < chars.count {
74+
guard let byte = UInt8(String(chars[i..<i+2]), radix: 16) else { return nil }
75+
data.append(byte)
76+
i += 2
77+
}
78+
return data
79+
}
80+
}

macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
44
case claude
55
case codex
66
case opencode
7+
case agent
78

89
var id: String { rawValue }
910

@@ -12,6 +13,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
1213
case .claude: return "Claude Code"
1314
case .codex: return "Codex"
1415
case .opencode: return "OpenCode"
16+
case .agent: return "Cursor Agent"
1517
}
1618
}
1719

@@ -20,6 +22,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable {
2022
case .claude: return "claude"
2123
case .codex: return "codex"
2224
case .opencode: return "opencode"
25+
case .agent: return "agent"
2326
}
2427
}
2528

@@ -75,6 +78,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
7578
case claude
7679
case codex
7780
case opencode
81+
case agent
7882

7983
var id: String { rawValue }
8084

@@ -84,6 +88,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
8488
case .claude: return "Claude Code"
8589
case .codex: return "Codex"
8690
case .opencode: return "OpenCode"
91+
case .agent: return "Cursor Agent"
8792
}
8893
}
8994

@@ -93,6 +98,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable {
9398
case .claude: return .claude
9499
case .codex: return .codex
95100
case .opencode: return .opencode
101+
case .agent: return .agent
96102
}
97103
}
98104

0 commit comments

Comments
 (0)