Skip to content

Commit 440cb07

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 440cb07

9 files changed

Lines changed: 343 additions & 2 deletions

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: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import Foundation
22

33
enum AgentHookInstaller {
4-
private static let notifyScriptMarker = "# Ghostree agent notification hook v7"
4+
private static let notifyScriptMarker = "# Ghostree agent notification hook v8"
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,
@@ -129,6 +137,9 @@ enum AgentHookInstaller {
129137
[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start"
130138
[ "$EVENT_TYPE" = "PermissionResponse" ] && EVENT_TYPE="Start"
131139
[ "$EVENT_TYPE" = "SessionEnd" ] && EVENT_TYPE="SessionEnd"
140+
[ "$EVENT_TYPE" = "stop" ] && EVENT_TYPE="Stop"
141+
[ "$EVENT_TYPE" = "pre_tool_use" ] && EVENT_TYPE="Start"
142+
[ "$EVENT_TYPE" = "post_tool_use" ] && EVENT_TYPE="Start"
132143
if [ -z "$EVENT_TYPE" ]; then
133144
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);')"
134145
CWD="$(pwd -P 2>/dev/null || pwd)"
@@ -149,11 +160,18 @@ enum AgentHookInstaller {
149160
if [ -z "$JSON_CWD" ]; then
150161
JSON_CWD=$(echo "$INPUT" | grep -oE '"worktree"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"')
151162
fi
163+
if [ -z "$JSON_CWD" ]; then
164+
JSON_CWD=$(echo "$INPUT" | grep -oE '"workspace_roots"[[:space:]]*:[[:space:]]*\\["[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"')
165+
fi
152166
if [ "$JSON_CWD" = "/" ]; then
153167
JSON_CWD=""
154168
fi
155169
if [ -n "$JSON_CWD" ]; then
156170
CWD="$JSON_CWD"
171+
elif [ -n "$CURSOR_PROJECT_DIR" ]; then
172+
CWD="$CURSOR_PROJECT_DIR"
173+
elif [ -n "$CLAUDE_PROJECT_DIR" ]; then
174+
CWD="$CLAUDE_PROJECT_DIR"
157175
else
158176
CWD="$(pwd -P 2>/dev/null || pwd)"
159177
fi
@@ -295,6 +313,102 @@ enum AgentHookInstaller {
295313
"""
296314
}
297315

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