diff --git a/CLAUDE.md b/CLAUDE.md
index ff935e21..59bd2bbf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -138,6 +138,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- Multi-agent: per-session agent (Claude, Codex, or OpenCode), chosen at creation (`A` key picker or `default_agent` config used by `a`). Stored in SQLite `agent` column; `internal/agent` owns binary name + launch command (`claude` / `codex resume ` / `codex fork ` / `opencode --session ` / `opencode --session --fork`).
- Codex status: hook-driven with Codex-specific pane checks — Codex hooks are incomplete (a wait/approval prompt and a permission approval fire no hook), so a definite pane state overrides a stale hook (`codexPaneWaiting`→waiting, `codexPaneRunning`→running); an at-rest pane lets the hook decide running/finished/idle, and with no hook at all it settles to idle (see the Codex branch in `UpdateStatus`, session.go). Same pipeline as Claude — `fleet hook-handler` is agent-neutral (`hook_event_name`/`session_id`/`prompt` match Claude). Hooks installed to `~/.codex/hooks.json` (`InjectCodexHooks`, only when `codex` on PATH). Codex has no SessionEnd → `dead` from tmux pane-death. Claude's pane heuristics never run for Codex sessions — the pane checks are Codex-specific.
- OpenCode status: driven entirely by a generated TS plugin (no pane scraping); same agent-neutral `fleet hook-handler` pipeline. Unlike Claude/Codex declarative hook JSON, OpenCode's hook mechanism is a JS/TS plugin, so fleet writes `~/.config/opencode/plugin/fleet-status.ts` (`InjectOpenCodePlugin`, only when `opencode` on PATH; the resolved fleet binary path is baked in). The plugin's `event` hook maps OpenCode-native bus events → fleet statuses via `spawnSync` (synchronous so the final status flushes before OpenCode exits, and ordering is preserved): `session.status{busy}`→running, `session.idle`→finished, `question.asked`/`permission.asked`→waiting, `question.replied`/`question.rejected`/`permission.replied`→running. **Both prompt families are live in OpenCode 1.14.x and are not interchangeable**: `question.*` is the AskUserQuestion tool prompt, `permission.*` is a tool-permission prompt (only fires if the user set `permission: ask`; OpenCode defaults to allow-all). Watching only one is the stuck-at-running bug — the branches are additive, never a swap. The `*.replied`/`*.rejected` listeners are belt-and-braces: OpenCode publishes `session.status{busy}` right after a reply anyway. Sub-agent sessions carry a `parentID` and are filtered so they don't flip the root session's status. No dir-trust seeding needed (OpenCode has no trust gate). No SessionEnd → `dead` from tmux pane-death. `UpdateStatus` routes OpenCode through `applyHookStatus` (shared with Codex); no pane heuristics run.
+- Cursor CLI status: pure hook-driven (no pane scraping), same `UpdateStatus` branch as OpenCode. Cursor CLI (`cursor-agent`) has real declarative hooks like Codex, but a flatter `hooks.json` schema (`{"version":1,"hooks":{"":[{"command","type"}]}}`, no matcher-grouping) — installed to `~/.cursor/hooks.json` via `InjectCursorHooks`/`cursor_hooks.go`, only when `cursor-agent` is on PATH, no dir-trust seeding (Cursor's permission model is a global allowlist in `~/.cursor/cli-config.json`, not a per-directory trust flag). Event mapping in `mapEventToStatus`: no `sessionStart` case (and fleet doesn't subscribe to it) — a freshly launched session is already idle via `initialRunStatus`, and mapping it to finished the way Claude's `SessionStart` does would flip an untouched session away from idle before any turn ran; `stop`→finished, `beforeSubmitPrompt`→running, `beforeShellExecution`→waiting/`afterShellExecution`→running (Cursor has no dedicated approval hook, so these bracket the interactive y/n prompt instead), `sessionEnd`→dead. No known local source for reading a Cursor chat's title, so `ReadAgentSessionName` falls back to the prompt heuristic like OpenCode.
- Codex trust: dir-trust pre-seeded to `~/.codex/config.toml` (`[projects.""] trust_level="trusted"`, via `EnsureCodexDirTrust`) before launch; hook-trust is a one-time global TUI prompt the user accepts on first Codex launch (persists in config.toml `[hooks.state]`).
- Session resume: captures the agent's session_id from hooks, uses `claude --resume ` / `codex resume ` on restart
- Editor: config.editor > $EDITOR > "code" (VS Code). `internal/editor` resolves the name to a command: CLI launcher on PATH if there is one, else `open -a ` against the installed bundle. That fallback is what makes JetBrains IDEs (GoLand, PyCharm, IntelliJ, …) work — Toolbox doesn't install `goland`/`pycharm` shims unless asked, so PATH-only lookup failed with "executable not found". Bundles are prefix-matched (`PyCharm Community Edition.app` → `pycharm`) across `/Applications`, `~/Applications`, and `~/Applications/JetBrains Toolbox`, scanned once per launch. The Settings editor cycler offers only editors this machine can actually launch (`editor.Available()`), so a preset can't be a dead option. Flags (`code -n`) are a CLI-only contract: a spec carrying them with no launcher on PATH errors rather than silently dropping them.
diff --git a/README.md b/README.md
index b90584d6..e0c66688 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Run 10 coding agents. Stay sane.
- A terminal cockpit for orchestrating Claude Code, Codex & OpenCode sessions in parallel.
+ A terminal cockpit for orchestrating Claude Code, Codex, OpenCode & Cursor sessions in parallel.
See which agents need you. Jump in, direct, jump out.
@@ -82,7 +82,7 @@ sudo apt install ./fleet_*.deb
- macOS or Linux
- [tmux](https://github.com/tmux/tmux) — `brew install tmux` / `apt install tmux` (≥ 3.3 recommended on Linux; older versions work with terminal passthrough disabled)
-- [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://developers.openai.com/codex), or [OpenCode](https://opencode.ai) — at least one
+- [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://developers.openai.com/codex), [OpenCode](https://opencode.ai), or [Cursor CLI](https://cursor.com/cli) — at least one
- Linux clipboard (optional): `wl-clipboard`, `xclip`, or `xsel` for copy-mode → system clipboard; without one, fleet falls back to OSC 52
## Quick Start
@@ -92,16 +92,16 @@ sudo apt install ./fleet_*.deb
fleet
# 'a' — new session in current repo (default agent)
-# 'A' — new session, pick the agent (Claude Code / Codex / OpenCode)
+# 'A' — new session, pick the agent (Claude Code / Codex / OpenCode / Cursor)
# 'n' — new session at any path (autocomplete)
# '?' — all keybindings
```
## Features
-### Claude Code, Codex, or OpenCode — per session
+### Claude Code, Codex, OpenCode, or Cursor — per session
-Pick the agent when you create a session: **`a`** fires instantly with your default agent, **`A`** opens a picker. Run Claude, Codex, and OpenCode sessions side by side in the same repo. Status, resume, and auto-naming work identically across all three — driven by each agent's own hook events, with pane heuristics filling the rare states that fire no hook.
+Pick the agent when you create a session: **`a`** fires instantly with your default agent, **`A`** opens a picker. Run Claude, Codex, OpenCode, and Cursor sessions side by side in the same repo. Status, resume, and auto-naming work identically across all four — driven by each agent's own hook events, with pane heuristics filling the rare states that fire no hook.
### Real-Time Status
@@ -131,7 +131,7 @@ Sessions live under their repo. Branch name, dirty state, and full PR status on
### And more
-- **Session resume** — restart with **`r`**, the agent picks up exactly where it left off (`claude --resume` / `codex resume` / `opencode --session`)
+- **Session resume** — restart with **`r`**, the agent picks up exactly where it left off (`claude --resume` / `codex resume` / `opencode --session` / `cursor-agent --resume`)
- **Idle-session suspend** — when memory runs low, fleet hibernates your most-idle sessions (each resumed agent holds ~400MB) and brings them back right where they were on **`Enter`**
- **Full terminal attach** — **`Enter`** for full PTY, **`Tab`** for split mode (beta), **`Ctrl+Q`** to detach
- **Auto-naming** — sessions title themselves from your prompt
@@ -143,7 +143,7 @@ Sessions live under their repo. Branch name, dirty state, and full PR status on
There are a dozen multi-agent session managers now. Most try to support every AI CLI under the sun by shimming keystrokes and scraping terminal output — broad support, shallow understanding of any one agent.
-fleet goes the other way: **deep integration with the agents that expose real hooks — Claude Code, Codex, and OpenCode.** Every feature is built on how those agents actually work — hook events, conversation resume, session IDs, prompt structure — not a generic "send keystrokes and hope" layer. Pick the agent per session (**`A`**), or set a default and fire with **`a`**.
+fleet goes the other way: **deep integration with the agents that expose real hooks — Claude Code, Codex, OpenCode, and Cursor.** Every feature is built on how those agents actually work — hook events, conversation resume, session IDs, prompt structure — not a generic "send keystrokes and hope" layer. Pick the agent per session (**`A`**), or set a default and fire with **`a`**.
### vs. the alternatives
@@ -156,14 +156,14 @@ fleet goes the other way: **deep integration with the agents that expose real ho
| **Open PR in browser** | ✅ | — | — | — |
| **Session resume** | ✅ | — | — | ✅ |
| **Git worktrees** | ✅ | ✅ | ✅ | ✅ |
-| **Hook-based multi-agent** | ✅ Claude + Codex + OpenCode | — | — | — |
+| **Hook-based multi-agent** | ✅ Claude + Codex + OpenCode + Cursor | — | — | — |
| **Many agents** (Gemini, Aider…) | — | ✅ | ✅ | ✅ |
| **Linux** | ✅ | ✅ | ✅ | ✅ |
| **No tmux dependency** | — | — | ✅ | — |
-**The trade-off is intentional.** claude-squad and ccmanager support 5+ agents — but treat them all the same, scraping the terminal and hoping. fleet supports Claude Code, Codex, and OpenCode, and *knows what they are*: it reads their hook events for instant status, resumes their conversations by session ID, knows your PR has 2 unresolved threads, names sessions from your actual prompt. That depth is only possible by going deep on the agents built to support it.
+**The trade-off is intentional.** claude-squad and ccmanager support 5+ agents — but treat them all the same, scraping the terminal and hoping. fleet supports Claude Code, Codex, OpenCode, and Cursor, and *knows what they are*: it reads their hook events for instant status, resumes their conversations by session ID, knows your PR has 2 unresolved threads, names sessions from your actual prompt. That depth is only possible by going deep on the agents built to support it.
-If you drive Claude Code, Codex, or OpenCode and want the tightest integration, this is it.
+If you drive Claude Code, Codex, OpenCode, or Cursor and want the tightest integration, this is it.
## Keybindings
@@ -175,7 +175,7 @@ If you drive Claude Code, Codex, or OpenCode and want the tightest integration,
| `Tab` | Focus/unfocus preview (split mode, beta) |
| `Space` | Jump to next waiting/finished session |
| `a` | New session (current repo, default agent) |
-| `A` | New session (pick agent: Claude Code / Codex / OpenCode) |
+| `A` | New session (pick agent: Claude Code / Codex / OpenCode / Cursor) |
| `n` | New session (any path, autocomplete) |
| `w` | New worktree session |
| `Y` | Quick approve waiting prompt |
diff --git a/changelog/unreleased/cursor-agent.md b/changelog/unreleased/cursor-agent.md
new file mode 100644
index 00000000..7733934c
--- /dev/null
+++ b/changelog/unreleased/cursor-agent.md
@@ -0,0 +1,5 @@
+---
+type: added
+---
+
+**Cursor joins the agent lineup.** You can now run sessions with Cursor CLI (`cursor-agent`) alongside Claude, Codex, and OpenCode — pick it from the `A` picker or set it as your `default_agent`.
diff --git a/cmd/fleet/hook_handler.go b/cmd/fleet/hook_handler.go
index a59d27c6..ab86f3e6 100644
--- a/cmd/fleet/hook_handler.go
+++ b/cmd/fleet/hook_handler.go
@@ -21,13 +21,16 @@ type hookPayload struct {
Prompt string `json:"prompt,omitempty"`
// Reason is set on SessionEnd: "clear", "logout", "prompt_input_exit", "other".
Reason string `json:"reason,omitempty"`
+ // Status is set on Cursor's stop hook: "completed", "aborted", or "error".
+ Status string `json:"status,omitempty"`
}
// mapEventToStatus maps a hook event to a fleet status string. Claude and Codex
// send Claude-style event names; the OpenCode status plugin sends OpenCode-native
-// names (session.busy/session.idle/permission.asked) — these are additive, the
-// other agents never emit them, so the handler stays agent-neutral.
-func mapEventToStatus(event string) string {
+// names (session.busy/session.idle/permission.asked); Cursor CLI's hooks.json
+// sends its own lowerCamelCase event names — these are all additive, no agent
+// emits another's names, so the handler stays agent-neutral.
+func mapEventToStatus(event, status string) string {
switch event {
case "UserPromptSubmit":
return "running"
@@ -62,11 +65,58 @@ func mapEventToStatus(event string) string {
// the next session.idle. Without this, waiting can stick if OpenCode
// doesn't re-emit session.status{busy} after an in-flight approval.
return "running"
+ // Cursor CLI events (from hooks.json, see internal/hooks/cursor_hooks.go).
+ // Both shell hooks map to running, never waiting: afterShellExecution's
+ // payload carries `output` and `duration`, so it fires when the command
+ // *completes*, not when an approval clears. Mapping beforeShellExecution to
+ // waiting would therefore hold the row at ◐ for the entire runtime of every
+ // command the agent runs — and Cursor is on the pure-hook path with no pane
+ // fallback to correct it, so `Space` would rotate to a busy session and `Y`
+ // would inject a stray "y"+Enter into a pane showing no prompt. Cursor has
+ // no dedicated approval hook, so fleet simply has no waiting signal for it.
+ //
+ // No sessionStart case: unlike Claude, Cursor's initial status (see
+ // initialRunStatus in session.go) starts idle, not running — so there's
+ // nothing for sessionStart to correct, and mapping it to "finished" (as
+ // Claude's SessionStart does) would immediately flip a freshly launched,
+ // untouched session to finished before any turn ran. It falls through to
+ // the default unmapped case below; fleet subscribes to no such hook (see
+ // cursorHookEvents in internal/hooks/cursor_hooks.go).
+ case "beforeSubmitPrompt":
+ return "running"
+ case "beforeShellExecution", "afterShellExecution":
+ return "running"
+ case "stop":
+ // Cursor's stop payload reports how the turn ended. Only "completed" is
+ // finished work; "error" surfaces as an error so a failed turn doesn't
+ // render with the same "done, come look" dot as a successful one.
+ // "aborted" is a user-initiated cancel — the turn is over, not broken.
+ if status == "error" {
+ return "error"
+ }
+ return "finished"
+ case "sessionEnd":
+ // Not "dead". Observed payload (cursor-agent 2026.07.23-e383d2b):
+ // {reason: "completed", final_status, duration_ms, ...} — this fires when
+ // a *conversation* ends, with the process alive and back at its prompt.
+ // Routing it to "dead" sets StatusError and writes a crash dump for a
+ // healthy session, then Reload All Sessions restarts a live agent.
+ // Cursor's real process death comes from tmux pane-death, same as Codex
+ // and OpenCode, neither of which maps an end-of-session hook to dead.
+ return "finished"
default:
return ""
}
}
+// isPromptSubmit reports whether event is a user-prompt-submission hook —
+// Claude/Codex's UserPromptSubmit, or Cursor's beforeSubmitPrompt equivalent
+// (see internal/hooks/cursor_hooks.go) — used to gate prompt-text capture and
+// prompt-count increments in handleHookHandler.
+func isPromptSubmit(event string) bool {
+ return event == "UserPromptSubmit" || event == "beforeSubmitPrompt"
+}
+
// isCompactSessionStart reports the SessionStart that Claude Code fires when a
// compaction completes. Its status must NOT be forced to "finished": on
// auto-compaction the turn is still running, so finishing here would flash a
@@ -104,7 +154,14 @@ func handleHookHandler() {
instanceID := os.Getenv("FLEET_INSTANCE_ID")
if instanceID == "" {
- log.Warn("hook-handler: no FLEET_INSTANCE_ID env var",
+ // Debug, not Warn: fleet installs Cursor's hooks into the user-global
+ // ~/.cursor/hooks.json, which the Cursor IDE reads too — so this fires
+ // for every prompt and shell command in the editor, none of which is
+ // fleet's business. At Warn it always writes, and since debug.log is
+ // size-truncated and the `!` bug-report flow pastes its last 100 lines
+ // into a public issue, an editing session would evict the diagnostics
+ // the report exists to carry.
+ log.Debug("hook-handler: no FLEET_INSTANCE_ID env var (not a fleet session)",
"event", payload.HookEventName,
"claudeSession", payload.SessionID,
"source", payload.Source,
@@ -120,7 +177,7 @@ func handleHookHandler() {
return
}
- status := mapEventToStatus(payload.HookEventName)
+ status := mapEventToStatus(payload.HookEventName, payload.Status)
// Special handling for Notification events.
if payload.HookEventName == "Notification" && payload.Matcher != nil {
@@ -148,9 +205,10 @@ func handleHookHandler() {
)
// Extract user prompt and prompt count.
+ promptSubmit := isPromptSubmit(payload.HookEventName)
var userPrompt string
var promptCount int
- if payload.HookEventName == "UserPromptSubmit" && payload.Prompt != "" {
+ if promptSubmit && payload.Prompt != "" {
userPrompt = payload.Prompt
}
@@ -165,7 +223,7 @@ func handleHookHandler() {
}
// Increment prompt count on new user prompt submissions.
- if payload.HookEventName == "UserPromptSubmit" {
+ if promptSubmit {
promptCount++
}
diff --git a/cmd/fleet/hook_handler_test.go b/cmd/fleet/hook_handler_test.go
index 6b55de04..59598ede 100644
--- a/cmd/fleet/hook_handler_test.go
+++ b/cmd/fleet/hook_handler_test.go
@@ -3,27 +3,69 @@ package main
import "testing"
func TestMapEventToStatus(t *testing.T) {
+ cases := []struct {
+ event string
+ status string
+ want string
+ }{
+ {"UserPromptSubmit", "", "running"},
+ {"Stop", "", "finished"},
+ {"PreCompact", "", "running"}, // /compact & auto-compaction: a multi-minute busy phase
+ {"PermissionRequest", "", "waiting"},
+ {"SessionStart", "", "finished"},
+ {"SessionEnd", "", "dead"},
+ {"Notification", "", ""}, // resolved separately by matcher
+ {"session.busy", "", "running"},
+ {"session.idle", "", "finished"},
+ {"session.error", "", "error"},
+ {"permission.asked", "", "waiting"},
+ {"permission.replied", "", "running"},
+ // Cursor CLI events (see internal/hooks/cursor_hooks.go).
+ {"beforeSubmitPrompt", "", "running"},
+ // Both shell hooks are running, never waiting: afterShellExecution fires
+ // on command completion (its payload carries output/duration), so waiting
+ // here would pin the row for the whole command. Cursor exposes no
+ // approval hook, so fleet has no waiting signal for it at all.
+ {"beforeShellExecution", "", "running"},
+ {"afterShellExecution", "", "running"},
+ // Cursor's stop carries how the turn ended.
+ {"stop", "completed", "finished"},
+ {"stop", "aborted", "finished"}, // user-initiated cancel: over, not broken
+ {"stop", "error", "error"},
+ {"stop", "", "finished"}, // absent status: treat as a normal finish
+ {"sessionEnd", "", "finished"}, // conversation end, not process death — see mapEventToStatus
+ // Cursor's lowerCamelCase "sessionStart" is deliberately unmapped —
+ // distinct from Claude/Codex's PascalCase "SessionStart" above, which
+ // does map to "finished". See the no-sessionStart-case comment in
+ // mapEventToStatus.
+ {"sessionStart", "", ""},
+ {"UnknownEvent", "", ""},
+ // status is only consulted for Cursor's stop; it must not leak into
+ // other agents' events that happen to carry one.
+ {"Stop", "error", "finished"},
+ }
+ for _, c := range cases {
+ if got := mapEventToStatus(c.event, c.status); got != c.want {
+ t.Errorf("mapEventToStatus(%q, %q) = %q, want %q", c.event, c.status, got, c.want)
+ }
+ }
+}
+
+func TestIsPromptSubmit(t *testing.T) {
cases := []struct {
event string
- want string
+ want bool
}{
- {"UserPromptSubmit", "running"},
- {"Stop", "finished"},
- {"PreCompact", "running"}, // /compact & auto-compaction: a multi-minute busy phase
- {"PermissionRequest", "waiting"},
- {"SessionStart", "finished"},
- {"SessionEnd", "dead"},
- {"Notification", ""}, // resolved separately by matcher
- {"session.busy", "running"},
- {"session.idle", "finished"},
- {"session.error", "error"},
- {"permission.asked", "waiting"},
- {"permission.replied", "running"},
- {"UnknownEvent", ""},
+ {"UserPromptSubmit", true},
+ {"beforeSubmitPrompt", true},
+ {"Stop", false},
+ {"stop", false},
+ {"sessionStart", false},
+ {"", false},
}
for _, c := range cases {
- if got := mapEventToStatus(c.event); got != c.want {
- t.Errorf("mapEventToStatus(%q) = %q, want %q", c.event, got, c.want)
+ if got := isPromptSubmit(c.event); got != c.want {
+ t.Errorf("isPromptSubmit(%q) = %v, want %v", c.event, got, c.want)
}
}
}
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 9ec073ad..56ee59f1 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -1,7 +1,7 @@
// Package agent describes the coding agents fleet can launch in a session
-// (Claude Code, OpenAI Codex, and OpenCode) and owns the per-agent divergence:
-// the binary name, display name, and the launch command (including resume/fork
-// forms).
+// (Claude Code, OpenAI Codex, OpenCode, and Cursor CLI) and owns the per-agent
+// divergence: the binary name, display name, and the launch command (including
+// resume/fork forms).
package agent
import "fmt"
@@ -13,6 +13,7 @@ const (
Claude Type = "claude"
Codex Type = "codex"
OpenCode Type = "opencode"
+ Cursor Type = "cursor"
// Default is the agent assumed when none is recorded (legacy sessions, empty config).
Default = Claude
@@ -28,6 +29,8 @@ func Parse(s string) Type {
return Codex
case OpenCode:
return OpenCode
+ case Cursor:
+ return Cursor
default:
return Default
}
@@ -40,6 +43,8 @@ func (t Type) Binary() string {
return "codex"
case OpenCode:
return "opencode"
+ case Cursor:
+ return "cursor-agent"
default:
return "claude"
}
@@ -52,6 +57,8 @@ func (t Type) DisplayName() string {
return "Codex"
case OpenCode:
return "OpenCode"
+ case Cursor:
+ return "Cursor"
default:
return "Claude"
}
@@ -60,6 +67,13 @@ func (t Type) DisplayName() string {
// String implements fmt.Stringer.
func (t Type) String() string { return string(t) }
+// SupportsFork reports whether the agent CLI can branch an existing conversation
+// into a new one (Claude's --fork-session, `codex fork`, opencode's --fork).
+// Cursor exposes no fork primitive, so BuildLaunchCmd has nowhere to put a
+// ForkID and would launch an empty conversation instead. UI gates must consult
+// this so a fork action can't dead-click.
+func (t Type) SupportsFork() bool { return t != Cursor }
+
// LaunchOpts carries the per-session details that shape the launch command.
type LaunchOpts struct {
// ResumeID resumes an existing agent conversation when set (and ForkID is empty).
@@ -88,6 +102,12 @@ type LaunchOpts struct {
// opencode
// opencode --session
// opencode --session --fork (fork)
+//
+// Cursor (hooks are seeded out-of-band; no fork primitive — fork-to-worktree
+// stays Claude-only):
+//
+// cursor-agent
+// cursor-agent --resume
func (t Type) BuildLaunchCmd(o LaunchOpts) string {
if t == Codex {
switch {
@@ -111,6 +131,16 @@ func (t Type) BuildLaunchCmd(o LaunchOpts) string {
}
}
+ if t == Cursor {
+ // No ForkID branch: Cursor has no fork primitive (see SupportsFork).
+ // Callers must gate on that rather than passing a ForkID here, which
+ // would silently launch an empty conversation.
+ if o.ResumeID != "" {
+ return fmt.Sprintf("cursor-agent --resume %s", o.ResumeID)
+ }
+ return "cursor-agent"
+ }
+
// Claude (default).
cmd := "claude"
if o.ForkID != "" {
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 4d294326..6e68a9ea 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -7,6 +7,7 @@ func TestParse(t *testing.T) {
"claude": Claude,
"codex": Codex,
"opencode": OpenCode,
+ "cursor": Cursor,
"": Claude, // empty → default
"unknown": Claude, // unrecognized → default
}
@@ -36,6 +37,12 @@ func TestBuildLaunchCmd(t *testing.T) {
{"opencode resume", OpenCode, LaunchOpts{ResumeID: "abc"}, "opencode --session abc"},
{"opencode fork", OpenCode, LaunchOpts{ForkID: "abc"}, "opencode --session abc --fork"},
{"opencode fork wins over resume", OpenCode, LaunchOpts{ResumeID: "r", ForkID: "f"}, "opencode --session f --fork"},
+ {"cursor new", Cursor, LaunchOpts{}, "cursor-agent"},
+ {"cursor resume", Cursor, LaunchOpts{ResumeID: "abc"}, "cursor-agent --resume abc"},
+ // Cursor has no fork primitive, so a ForkID has nowhere to go. Callers
+ // must gate on SupportsFork rather than relying on this — pinned here so
+ // the drop stays deliberate and visible.
+ {"cursor ignores fork id", Cursor, LaunchOpts{ForkID: "abc"}, "cursor-agent"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -46,11 +53,28 @@ func TestBuildLaunchCmd(t *testing.T) {
}
}
+func TestSupportsFork(t *testing.T) {
+ // Every agent whose BuildLaunchCmd honors ForkID must report true, and the
+ // one that drops it must report false — otherwise a UI fork gate lights up
+ // a row that silently launches an empty conversation.
+ for _, typ := range []Type{Claude, Codex, OpenCode} {
+ if !typ.SupportsFork() {
+ t.Errorf("%s should support fork", typ)
+ }
+ if got := typ.BuildLaunchCmd(LaunchOpts{ForkID: "abc"}); got == typ.BuildLaunchCmd(LaunchOpts{}) {
+ t.Errorf("%s claims fork support but ForkID changes nothing: %q", typ, got)
+ }
+ }
+ if Cursor.SupportsFork() {
+ t.Errorf("Cursor has no fork primitive but SupportsFork() = true")
+ }
+}
+
func TestBinaryAndDisplayName(t *testing.T) {
- if Claude.Binary() != "claude" || Codex.Binary() != "codex" || OpenCode.Binary() != "opencode" {
- t.Errorf("unexpected Binary(): claude=%q codex=%q opencode=%q", Claude.Binary(), Codex.Binary(), OpenCode.Binary())
+ if Claude.Binary() != "claude" || Codex.Binary() != "codex" || OpenCode.Binary() != "opencode" || Cursor.Binary() != "cursor-agent" {
+ t.Errorf("unexpected Binary(): claude=%q codex=%q opencode=%q cursor=%q", Claude.Binary(), Codex.Binary(), OpenCode.Binary(), Cursor.Binary())
}
- if Claude.DisplayName() != "Claude" || Codex.DisplayName() != "Codex" || OpenCode.DisplayName() != "OpenCode" {
- t.Errorf("unexpected DisplayName(): claude=%q codex=%q opencode=%q", Claude.DisplayName(), Codex.DisplayName(), OpenCode.DisplayName())
+ if Claude.DisplayName() != "Claude" || Codex.DisplayName() != "Codex" || OpenCode.DisplayName() != "OpenCode" || Cursor.DisplayName() != "Cursor" {
+ t.Errorf("unexpected DisplayName(): claude=%q codex=%q opencode=%q cursor=%q", Claude.DisplayName(), Codex.DisplayName(), OpenCode.DisplayName(), Cursor.DisplayName())
}
}
diff --git a/internal/config/config.go b/internal/config/config.go
index e2486944..a670d75f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -58,7 +58,7 @@ type Config struct {
// Telemetry is the legacy on/off flag, kept only for migration — new writes
// go to TelemetryMode. See GetTelemetryMode.
Telemetry *bool `json:"telemetry,omitempty"`
- DefaultAgent string `json:"default_agent,omitempty"` // "claude" or "codex"
+ DefaultAgent string `json:"default_agent,omitempty"` // "claude", "codex", "opencode", or "cursor"
DrawerHeight int `json:"drawer_height,omitempty"` // terminal-drawer body rows (default 12)
// SessionSuspendMode controls auto-hibernation of idle sessions under memory
// pressure: "off", "light" (default), "balanced", or "aggressive". Read via
@@ -127,7 +127,7 @@ type Config struct {
func (c *Config) IsFirstRun() bool { return !c.loadedFromDisk }
// GetDefaultAgent returns the default coding agent for new sessions ("claude",
-// "codex", or "opencode"). The stored value is normalized (trimmed +
+// "codex", "opencode", or "cursor"). The stored value is normalized (trimmed +
// lower-cased) so hand-edited configs like "Codex" or " codex " resolve
// correctly instead of silently falling back.
func (c *Config) GetDefaultAgent() string {
@@ -136,6 +136,8 @@ func (c *Config) GetDefaultAgent() string {
return "codex"
case "opencode":
return "opencode"
+ case "cursor":
+ return "cursor"
default:
return "claude"
}
diff --git a/internal/diagnostics/diagnostics.go b/internal/diagnostics/diagnostics.go
index 17b41325..42a27752 100644
--- a/internal/diagnostics/diagnostics.go
+++ b/internal/diagnostics/diagnostics.go
@@ -24,6 +24,7 @@ type Report struct {
TmuxVersion string
ClaudeVersion string
CodexVersion string
+ CursorVersion string
GhVersion string
Config string
SessionCount int
@@ -71,6 +72,7 @@ func Collect(version string, sessionCount int) *Report {
r.TmuxVersion = runCmd("tmux", "-V")
r.ClaudeVersion = runCmd("claude", "--version")
r.CodexVersion = firstLine(runCmd("codex", "--version"))
+ r.CursorVersion = firstLine(runCmd("cursor-agent", "--version"))
r.GhVersion = firstLine(runCmd("gh", "--version"))
r.TerminalEnv = collectTerminalEnv()
@@ -214,6 +216,9 @@ func (r *Report) FormatEnvironmentMarkdown(includeLogs bool) string {
if r.CodexVersion != "" {
fmt.Fprintf(&b, "- **Codex CLI**: %s\n", sanitize(r.CodexVersion))
}
+ if r.CursorVersion != "" {
+ fmt.Fprintf(&b, "- **Cursor CLI**: %s\n", sanitize(r.CursorVersion))
+ }
if r.GhVersion != "" {
fmt.Fprintf(&b, "- **gh CLI**: %s\n", r.GhVersion)
}
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
new file mode 100644
index 00000000..fe06185c
--- /dev/null
+++ b/internal/hooks/cursor_hooks.go
@@ -0,0 +1,236 @@
+package hooks
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/brizzai/fleet/internal/debuglog"
+)
+
+// cursorHookEvents lists the Cursor CLI hook events fleet subscribes to.
+// Cursor's payload field names match Claude's (hook_event_name, session_id,
+// prompt on beforeSubmitPrompt), so `fleet hook-handler` is reused unchanged;
+// only the event names and hooks.json shape are Cursor-specific.
+//
+// No sessionStart: fleet's initial status for a freshly launched Cursor
+// session is already idle (see initialRunStatus, session.go), and
+// mapEventToStatus (cmd/fleet/hook_handler.go) has no case for it — installing
+// the hook would only spawn an extra `fleet hook-handler` process per launch
+// for an event fleet doesn't act on.
+var cursorHookEvents = []string{
+ "beforeSubmitPrompt",
+ "beforeShellExecution",
+ "afterShellExecution",
+ "stop",
+ "sessionEnd",
+}
+
+// cursorHookEntry represents a single hook entry in Cursor's hooks.json. Unlike
+// Claude/Codex's nested {matcher, hooks:[...]} shape, Cursor's schema is flat:
+// each event maps directly to an array of entries.
+type cursorHookEntry struct {
+ Command string `json:"command"`
+ Type string `json:"type,omitempty"`
+}
+
+// GetCursorConfigDir returns the Cursor CLI config directory (~/.cursor). No
+// env override is documented for Cursor (unlike CODEX_HOME/CLAUDE_CONFIG_DIR).
+func GetCursorConfigDir() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(os.TempDir(), ".cursor")
+ }
+ return filepath.Join(home, ".cursor")
+}
+
+// InjectCursorHooks merges fleet hook entries into Cursor's hooks.json,
+// preserving any existing user hooks. Returns true if the file was written
+// (changed).
+//
+// hooks.json shape: {"version": 1, "hooks": {"": [ {"command","type"} ]}}
+// — flat per event, so the merge is simpler than Claude/Codex's matcher-grouped
+// shape and doesn't reuse mergeHookEvent/claudeHookMatcher.
+func InjectCursorHooks(configDir string) (bool, error) {
+ hooksPath := filepath.Join(configDir, "hooks.json")
+
+ var root map[string]json.RawMessage
+ orig, err := os.ReadFile(hooksPath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ debuglog.Logger.Error("cursor hooks: failed to read hooks.json", "path", hooksPath, "err", err)
+ return false, fmt.Errorf("read hooks.json: %w", err)
+ }
+ root = make(map[string]json.RawMessage)
+ } else {
+ if err := json.Unmarshal(orig, &root); err != nil {
+ debuglog.Logger.Error("cursor hooks: failed to parse hooks.json", "path", hooksPath, "err", err)
+ return false, fmt.Errorf("parse hooks.json: %w", err)
+ }
+ }
+ if root == nil {
+ // The file held a bare JSON null: Unmarshal leaves the map nil and
+ // returns no error, so the parse check above lets it through and the
+ // root["hooks"] assignment below would panic. Same hazard the events
+ // map guards against further down.
+ root = make(map[string]json.RawMessage)
+ }
+
+ var events map[string]json.RawMessage
+ if raw, ok := root["hooks"]; ok {
+ // Fail closed: emptying `events` here would drop the user's existing hooks
+ // on the write below, contradicting this function's preserve-user-hooks
+ // contract. Refuse to touch the file when the section is unparseable.
+ if err := json.Unmarshal(raw, &events); err != nil {
+ debuglog.Logger.Error("cursor hooks: failed to parse hooks section", "err", err)
+ return false, fmt.Errorf("parse hooks section (refusing to overwrite user hooks): %w", err)
+ }
+ }
+ if events == nil {
+ // Either "hooks" was absent, or present as JSON null (Unmarshal leaves
+ // the map nil in both cases) — either way start from an empty map so
+ // the assignment below doesn't panic on a nil map.
+ events = make(map[string]json.RawMessage)
+ }
+
+ for _, event := range cursorHookEvents {
+ merged, err := mergeCursorHookEvent(events[event])
+ if err != nil {
+ debuglog.Logger.Error("cursor hooks: failed to parse event entries", "event", event, "err", err)
+ return false, fmt.Errorf("parse %q entries (refusing to overwrite user hooks): %w", event, err)
+ }
+ events[event] = merged
+ }
+
+ eventsRaw, err := json.Marshal(events)
+ if err != nil {
+ return false, fmt.Errorf("marshal hooks: %w", err)
+ }
+ root["hooks"] = eventsRaw
+
+ // Only set version on a fresh file. Overwriting an existing value could
+ // silently downgrade a user's hooks.json metadata if Cursor bumps its
+ // schema version.
+ if _, ok := root["version"]; !ok {
+ versionRaw, err := json.Marshal(1)
+ if err != nil {
+ return false, fmt.Errorf("marshal version: %w", err)
+ }
+ root["version"] = versionRaw
+ }
+
+ finalData, err := json.MarshalIndent(root, "", " ")
+ if err != nil {
+ return false, fmt.Errorf("marshal hooks.json: %w", err)
+ }
+
+ // Idempotent: skip the write (and the "changed" signal) if nothing changed.
+ if bytes.Equal(bytes.TrimSpace(orig), bytes.TrimSpace(finalData)) {
+ return false, nil
+ }
+
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ return false, fmt.Errorf("create config dir: %w", err)
+ }
+ // A uniquely-named temp file (like WriteStatusFile) rather than a fixed
+ // "hooks.json.tmp": multiple fleet instances can start concurrently, and a
+ // shared tmp path would let one instance's rename race another's, failing
+ // with ENOENT or losing a write and leaving Cursor hooks uninstalled.
+ tmp, err := os.CreateTemp(configDir, "hooks.*.json.tmp")
+ if err != nil {
+ return false, fmt.Errorf("create hooks.json.tmp: %w", err)
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath) // no-op once the rename below succeeds
+ if _, err := tmp.Write(finalData); err != nil {
+ tmp.Close()
+ return false, fmt.Errorf("write hooks.json.tmp: %w", err)
+ }
+ if err := tmp.Chmod(0644); err != nil {
+ tmp.Close()
+ return false, fmt.Errorf("chmod hooks.json.tmp: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return false, fmt.Errorf("close hooks.json.tmp: %w", err)
+ }
+ if err := os.Rename(tmpPath, hooksPath); err != nil {
+ debuglog.Logger.Error("cursor hooks: failed to rename hooks.json.tmp", "err", err)
+ return false, fmt.Errorf("rename hooks.json: %w", err)
+ }
+
+ debuglog.Logger.Info("cursor hooks injected", "path", hooksPath)
+ return true, nil
+}
+
+// cursorHookEntryProbe is unmarshaled only to test whether a raw entry is
+// fleet's own command hook (by its "command" field). Every other field on the
+// raw entry — matcher, timeout, failClosed, loop_limit, or a prompt hook's
+// "prompt" (which carries no "command" at all) — is left untouched by
+// mergeCursorHookEvent; unmarshaling straight into []cursorHookEntry would
+// silently drop them on the re-marshal below.
+type cursorHookEntryProbe struct {
+ Command string `json:"command"`
+}
+
+// mergeCursorHookEvent adds fleet's hook to an event's flat entry array,
+// preserving any existing (non-fleet) entries — including fields
+// []cursorHookEntry doesn't model — and updating the command path in place if
+// it changed (e.g. after a rebuild).
+//
+// Fail closed: an existing, non-empty entry that isn't a parseable JSON array
+// is refused rather than silently discarded — treating unmarshal failure as
+// "no entries" would clobber whatever the user (or another tool) put there,
+// contradicting InjectCursorHooks' preserve-user-hooks contract.
+func mergeCursorHookEvent(existing json.RawMessage) (json.RawMessage, error) {
+ var entries []json.RawMessage
+ if len(existing) > 0 {
+ if err := json.Unmarshal(existing, &entries); err != nil {
+ return nil, err
+ }
+ }
+
+ currentCmd := GetHookCommand()
+
+ for i, raw := range entries {
+ var probe cursorHookEntryProbe
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ continue // not a JSON object at all — leave it untouched
+ }
+ // Anything that isn't fleet's own command hook is left alone. This is
+ // the check that skips other users' command hooks *and* non-command
+ // entries such as prompt hooks, which unmarshal cleanly into the probe
+ // with an empty Command rather than erroring above.
+ if !isFleetHook(probe.Command) {
+ continue
+ }
+ if probe.Command == currentCmd {
+ return json.Marshal(entries)
+ }
+ // Patch only the "command" key so every other field on this entry
+ // (type, timeout, ...) survives untouched.
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ return nil, err
+ }
+ cmdRaw, err := json.Marshal(currentCmd)
+ if err != nil {
+ return nil, err
+ }
+ fields["command"] = cmdRaw
+ patched, err := json.Marshal(fields)
+ if err != nil {
+ return nil, err
+ }
+ entries[i] = patched
+ return json.Marshal(entries)
+ }
+
+ newEntry, err := json.Marshal(cursorHookEntry{Command: currentCmd, Type: "command"})
+ if err != nil {
+ return nil, err
+ }
+ entries = append(entries, newEntry)
+ return json.Marshal(entries)
+}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
new file mode 100644
index 00000000..43cdeb9d
--- /dev/null
+++ b/internal/hooks/cursor_hooks_test.go
@@ -0,0 +1,239 @@
+package hooks
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestInjectCursorHooks(t *testing.T) {
+ dir := t.TempDir()
+
+ // First install: should write and report changed.
+ changed, err := InjectCursorHooks(dir)
+ if err != nil {
+ t.Fatalf("InjectCursorHooks: %v", err)
+ }
+ if !changed {
+ t.Errorf("expected changed=true on first install")
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "hooks.json"))
+ if err != nil {
+ t.Fatalf("read hooks.json: %v", err)
+ }
+ var root struct {
+ Version int `json:"version"`
+ Hooks map[string][]cursorHookEntry `json:"hooks"`
+ }
+ if err := json.Unmarshal(data, &root); err != nil {
+ t.Fatalf("parse hooks.json: %v\n%s", err, data)
+ }
+ if root.Version != 1 {
+ t.Errorf("expected version 1, got %d", root.Version)
+ }
+ for _, event := range cursorHookEvents {
+ entries, ok := root.Hooks[event]
+ if !ok || len(entries) == 0 {
+ t.Fatalf("event %q missing fleet hook", event)
+ }
+ e := entries[0]
+ if e.Type != "command" || !strings.Contains(e.Command, "hook-handler") {
+ t.Errorf("event %q: unexpected hook entry %+v", event, e)
+ }
+ }
+ // Re-install is a no-op: isFleetHook also matches the binary-name-independent
+ // "--fleet-hook" marker arg, which GetHookCommand always appends regardless
+ // of what the running binary is named — so this holds under `go test` too,
+ // not just a binary literally named "fleet".
+ changed, err = InjectCursorHooks(dir)
+ if err != nil {
+ t.Fatalf("InjectCursorHooks (2nd): %v", err)
+ }
+ if changed {
+ t.Errorf("expected changed=false on re-install, got true")
+ }
+}
+
+func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
+ dir := t.TempDir()
+ // Pre-existing user hook on an event we don't manage (afterFileEdit); a
+ // managed event with a pre-existing user hook is covered separately by
+ // TestInjectCursorHooksPreservesUnknownFieldsAndPromptHooks.
+ seed := `{"version":1,"hooks":{"afterFileEdit":[{"command":".cursor/hooks/format.sh"}]}}`
+ if err := os.WriteFile(filepath.Join(dir, "hooks.json"), []byte(seed), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := InjectCursorHooks(dir); err != nil {
+ t.Fatalf("InjectCursorHooks: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "hooks.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), ".cursor/hooks/format.sh") {
+ t.Errorf("user hook was clobbered:\n%s", data)
+ }
+ if !strings.Contains(string(data), "afterFileEdit") {
+ t.Errorf("user event removed:\n%s", data)
+ }
+ if !strings.Contains(string(data), "stop") {
+ t.Errorf("fleet event not added:\n%s", data)
+ }
+}
+
+func TestInjectCursorHooksPreservesUnknownFieldsAndPromptHooks(t *testing.T) {
+ dir := t.TempDir()
+ // A managed event with: (1) a user's command-hook entry carrying fields
+ // []cursorHookEntry doesn't model (matcher, timeout), and (2) a prompt-hook
+ // entry with no "command" field at all. Both must survive with their fields
+ // intact (re-marshaling via json.MarshalIndent means whitespace/key order
+ // can change, but no data is lost); only fleet's own entry gets appended
+ // alongside them.
+ seed := `{"version":1,"hooks":{"stop":[` +
+ `{"command":"./hooks/notify.sh","matcher":"^Bash$","timeout":30},` +
+ `{"type":"prompt","prompt":"Summarize what changed"}` +
+ `]}}`
+ path := filepath.Join(dir, "hooks.json")
+ if err := os.WriteFile(path, []byte(seed), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := InjectCursorHooks(dir); err != nil {
+ t.Fatalf("InjectCursorHooks: %v", err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var root struct {
+ Hooks map[string][]json.RawMessage `json:"hooks"`
+ }
+ if err := json.Unmarshal(data, &root); err != nil {
+ t.Fatalf("parse hooks.json: %v\n%s", err, data)
+ }
+ entries := root.Hooks["stop"]
+ if len(entries) != 3 {
+ t.Fatalf("expected 3 entries on stop (2 preserved + fleet's), got %d:\n%s", len(entries), data)
+ }
+
+ var userCmdHook, promptHook map[string]any
+ if err := json.Unmarshal(entries[0], &userCmdHook); err != nil {
+ t.Fatal(err)
+ }
+ if userCmdHook["matcher"] != "^Bash$" || userCmdHook["timeout"] != float64(30) {
+ t.Errorf("user command-hook entry lost unrelated fields: %+v", userCmdHook)
+ }
+ if err := json.Unmarshal(entries[1], &promptHook); err != nil {
+ t.Fatal(err)
+ }
+ if promptHook["prompt"] != "Summarize what changed" || promptHook["type"] != "prompt" {
+ t.Errorf("prompt-hook entry (no \"command\" field) was corrupted: %+v", promptHook)
+ }
+ if !strings.Contains(string(entries[2]), "hook-handler") {
+ t.Errorf("fleet's own entry missing or malformed: %s", entries[2])
+ }
+}
+
+func TestInjectCursorHooksRefusesMalformedEventEntries(t *testing.T) {
+ dir := t.TempDir()
+ // One of our managed events holds something that isn't a []cursorHookEntry
+ // array (e.g. hand-edited or written by another tool in a different shape).
+ // The whole write must be refused rather than silently dropping it.
+ seed := `{"version":1,"hooks":{"stop":{"command":"not-an-array"}}}`
+ path := filepath.Join(dir, "hooks.json")
+ if err := os.WriteFile(path, []byte(seed), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := InjectCursorHooks(dir); err == nil {
+ t.Fatal("expected InjectCursorHooks to error on malformed event entries, got nil")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != seed {
+ t.Errorf("hooks.json was modified despite the refusal:\nwant %s\ngot %s", seed, data)
+ }
+}
+
+func TestInjectCursorHooksHandlesNullHooksSection(t *testing.T) {
+ dir := t.TempDir()
+ // json.Unmarshal leaves a map nil (not an error) when the JSON value is
+ // null, so a hand-edited "hooks": null must not panic on the later
+ // events[event] = ... assignment.
+ seed := `{"version":1,"hooks":null}`
+ path := filepath.Join(dir, "hooks.json")
+ if err := os.WriteFile(path, []byte(seed), 0644); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := InjectCursorHooks(dir)
+ if err != nil {
+ t.Fatalf("InjectCursorHooks: %v", err)
+ }
+ if !changed {
+ t.Errorf("expected changed=true when populating a null hooks section")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "stop") {
+ t.Errorf("fleet event not added:\n%s", data)
+ }
+}
+
+func TestInjectCursorHooksHandlesNullRoot(t *testing.T) {
+ dir := t.TempDir()
+ // Same nil-map hazard as the null hooks section above, one level up: a
+ // hooks.json holding a bare null unmarshals into a nil root map without
+ // erroring, so the root["hooks"] assignment would panic and take the whole
+ // TUI down at startup (InjectCursorHooks runs from loadSessions).
+ path := filepath.Join(dir, "hooks.json")
+ if err := os.WriteFile(path, []byte("null"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := InjectCursorHooks(dir)
+ if err != nil {
+ t.Fatalf("InjectCursorHooks: %v", err)
+ }
+ if !changed {
+ t.Errorf("expected changed=true when populating a null root")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "stop") {
+ t.Errorf("fleet event not added:\n%s", data)
+ }
+}
+
+func TestMergeCursorHookEventMigratesStaleFleetPath(t *testing.T) {
+ // The patch path in mergeCursorHookEvent runs on every install whose binary
+ // path changed (brew version bump, `go install` over a dev build). If it
+ // ever regressed to appending instead of patching, every launch would add
+ // another entry and Cursor would spawn N handlers per event — the exact
+ // failure the marker arg exists to prevent.
+ seed := `[{"command":"/old/path/fleet hook-handler --fleet-hook","type":"command","timeout":30}]`
+ out, err := mergeCursorHookEvent(json.RawMessage(seed))
+ if err != nil {
+ t.Fatalf("mergeCursorHookEvent: %v", err)
+ }
+ var entries []map[string]any
+ if err := json.Unmarshal(out, &entries); err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected the stale entry patched in place, got %d entries: %s", len(entries), out)
+ }
+ if got := entries[0]["command"]; got != GetHookCommand() {
+ t.Errorf("command not migrated: got %v, want %v", got, GetHookCommand())
+ }
+ // Sibling keys on the entry must survive the patch.
+ if _, ok := entries[0]["timeout"]; !ok {
+ t.Errorf("unrelated field dropped by the patch: %s", out)
+ }
+}
diff --git a/internal/hooks/hook_watcher.go b/internal/hooks/hook_watcher.go
index f93d4b9e..32662cc7 100644
--- a/internal/hooks/hook_watcher.go
+++ b/internal/hooks/hook_watcher.go
@@ -259,13 +259,18 @@ func emitHookMetrics(prev, curr *HookStatus) {
if curr == nil {
return
}
+ // Each agent names these two moments differently — Claude/Codex PascalCase,
+ // OpenCode dotted, Cursor lowerCamelCase. Matching only Claude's spelling
+ // would silently drop prompt/response volume and the first-response
+ // onboarding milestone for every non-Claude user, so they register as having
+ // installed fleet and never gotten a single agent response.
switch curr.Event {
- case "UserPromptSubmit":
+ case "UserPromptSubmit", "beforeSubmitPrompt":
if prev == nil || curr.PromptCount > prev.PromptCount {
analytics.Track(analytics.EventClaudePromptSubmitted, nil)
}
- case "Stop":
- if prev == nil || prev.Event != "Stop" || !prev.UpdatedAt.Equal(curr.UpdatedAt) {
+ case "Stop", "stop", "session.idle":
+ if prev == nil || !isResponseEnd(prev.Event) || !prev.UpdatedAt.Equal(curr.UpdatedAt) {
analytics.Track(analytics.EventClaudeResponseReceived, nil)
if analytics.MarkOnboardingMilestone(analytics.MilestoneFirstClaudeResponse) {
analytics.Track(analytics.EventOnboardingFirstClaudeResponse, map[string]interface{}{
@@ -275,3 +280,9 @@ func emitHookMetrics(prev, curr *HookStatus) {
}
}
}
+
+// isResponseEnd reports whether event is any agent's end-of-response hook, used
+// to dedupe repeat writes of the same event in emitHookMetrics.
+func isResponseEnd(event string) bool {
+ return event == "Stop" || event == "stop" || event == "session.idle"
+}
diff --git a/internal/session/agent_name.go b/internal/session/agent_name.go
index 1eebb15d..50b2edc9 100644
--- a/internal/session/agent_name.go
+++ b/internal/session/agent_name.go
@@ -13,6 +13,10 @@ func ReadAgentSessionName(a agent.Type, sessionID, projectPath string) string {
case agent.OpenCode:
// OpenCode titles its sessions too, but fleet doesn't read them yet.
return ""
+ case agent.Cursor:
+ // Cursor CLI's local chat storage has no documented title/session-id
+ // mapping fleet can read; falls back to the prompt heuristic like OpenCode.
+ return ""
default:
// Claude, and legacy rows with no agent recorded.
return ReadClaudeSessionName(sessionID, projectPath)
diff --git a/internal/session/session.go b/internal/session/session.go
index c14b248d..0d1dc2ea 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -169,10 +169,10 @@ func (s *Session) buildAgentCmd() string {
}
// initialRunStatus is the status to show right after launching the agent.
-// Codex and OpenCode fire no event until their first turn, so they start idle
-// (sitting at their prompt) rather than flashing running.
+// Codex, OpenCode, and Cursor fire no event until their first turn, so they
+// start idle (sitting at their prompt) rather than flashing running.
func (s *Session) initialRunStatus() Status {
- if s.Agent == agent.Codex || s.Agent == agent.OpenCode {
+ if s.Agent == agent.Codex || s.Agent == agent.OpenCode || s.Agent == agent.Cursor {
return StatusIdle
}
return StatusRunning
@@ -286,8 +286,8 @@ func (s *Session) getCapturer() PaneCapturer {
// conversationActivePastHook reports whether the Claude conversation transcript
// shows lead-turn activity that resumed after the current waiting hook — the
// out-of-pane tiebreaker for the between-bursts frame where the pane is
-// indistinguishable from a finished idle prompt. Claude only; Codex has no Claude
-// transcript. MUST NOT be called while holding s.mu — it does disk I/O.
+// indistinguishable from a finished idle prompt. Claude only; Codex/Cursor have
+// no Claude transcript. MUST NOT be called while holding s.mu — it does disk I/O.
func (s *Session) conversationActivePastHook() bool {
if s.convActiveFn != nil {
return s.convActiveFn()
@@ -303,7 +303,7 @@ func (s *Session) conversationActivePastHook() bool {
cacheTS := s.convLeadTimestamp
s.mu.RUnlock()
- if agentType == agent.Codex || claudeID == "" {
+ if agentType == agent.Codex || agentType == agent.Cursor || claudeID == "" {
return false
}
@@ -974,11 +974,16 @@ func (s *Session) UpdateStatus() {
// including the waiting→running transition after a permission reply, so the
// hook is always trusted and no pane scraping is needed. Before the first
// event there is no hook — the session sits idle at its prompt.
- if s.Agent == agent.OpenCode {
+ //
+ // Cursor CLI rides the same path: its hooks.json events (mapped in
+ // cmd/fleet/hook_handler.go) report running/waiting/finished/dead directly,
+ // with no pane scraping. Before the first event there is no hook — the
+ // session sits idle at its prompt.
+ if s.Agent == agent.OpenCode || s.Agent == agent.Cursor {
if !hasHook {
if oldStatus != StatusIdle {
s.SetStatus(StatusIdle)
- log.Info("status changed (opencode no-hook)", "old", oldStatus, "new", StatusIdle)
+ log.Info("status changed (hook-only agent, no-hook)", "old", oldStatus, "new", StatusIdle)
}
return
}
diff --git a/internal/ui/app.go b/internal/ui/app.go
index ec1c45ec..546cbd0d 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -4045,6 +4045,10 @@ func (h *Home) forkSelected() tea.Cmd {
h.setError(fmt.Errorf("cannot fork: no session selected"))
return nil
}
+ if !s.Agent.SupportsFork() {
+ h.setError(fmt.Errorf("cannot fork: %s has no fork command", s.Agent.DisplayName()))
+ return nil
+ }
if s.GetClaudeSessionID() == "" {
h.setError(fmt.Errorf("cannot fork: session has no Claude conversation ID yet"))
return nil
@@ -6912,6 +6916,15 @@ func (h *Home) loadSessions() tea.Msg {
debuglog.Logger.Error("opencode plugin inject failed", "err", err)
}
}
+ // Install Cursor CLI hooks too, but only if cursor-agent is present — never
+ // create ~/.cursor for users who don't have it.
+ var cursorHookErr error
+ if _, err := exec.LookPath("cursor-agent"); err == nil {
+ if _, err := hooks.InjectCursorHooks(hooks.GetCursorConfigDir()); err != nil {
+ debuglog.Logger.Error("cursor hooks inject failed", "err", err)
+ cursorHookErr = err
+ }
+ }
// Route tmux copy-mode selections to the system clipboard (pbcopy on
// macOS; wl-copy/xclip/xsel on Linux), so drag/click-to-copy works on
// terminals that block OSC 52 (iTerm2 default) or don't support it (Apple
@@ -6926,6 +6939,13 @@ func (h *Home) loadSessions() tea.Msg {
if _, err := exec.LookPath("claude"); err != nil {
warning = "claude CLI not found — install Claude Code to create sessions"
}
+ // A failed Cursor hook install is otherwise invisible: Cursor rides the
+ // pure-hook status path, so with no hooks.json every one of its sessions
+ // resets to idle on each tick — never running, never waiting — with nothing
+ // on screen explaining why. Surface it like the missing-claude warning.
+ if cursorHookErr != nil && warning == "" {
+ warning = "cursor hooks install failed — Cursor sessions will show as idle"
+ }
// Load persisted PR cache. A failure here is non-fatal — the bootstrap
// will just re-fetch from gh as usual.
@@ -7157,6 +7177,16 @@ func (h *Home) sessionContextMenu() (string, []ContextMenuItem) {
unread.Enabled = true
}
+ forkSession := ContextMenuItem{ID: "fork", Label: "Fork Session", Shortcut: "f", Key: "f"}
+ switch {
+ case !s.Agent.SupportsFork():
+ forkSession.Note = s.Agent.DisplayName() + " has no fork"
+ case !resumable:
+ forkSession.Note = "no session id yet"
+ default:
+ forkSession.Enabled = true
+ }
+
forkWorktree := ContextMenuItem{ID: "fork_worktree", Label: "Fork to Worktree", Shortcut: "F", Key: "F"}
switch {
case s.Agent != agent.Claude:
@@ -7192,11 +7222,7 @@ func (h *Home) sessionContextMenu() (string, []ContextMenuItem) {
Enabled: h.hasPRForCursor(),
Note: "no PR",
},
- {
- ID: "fork", Label: "Fork Session", Shortcut: "f", Key: "f",
- Enabled: resumable,
- Note: "no session id yet",
- },
+ forkSession,
forkWorktree,
suspend,
h.snoozeMenuItem(),
diff --git a/internal/ui/session_create.go b/internal/ui/session_create.go
index 40ae5ba5..b5fbcf4a 100644
--- a/internal/ui/session_create.go
+++ b/internal/ui/session_create.go
@@ -65,7 +65,7 @@ func (d *SessionCreateDialog) Update(msg tea.Msg) (*SessionCreateDialog, tea.Cmd
// agentCycle is the order the picker steps through (left/right). New agents are
// appended; the create path validates the binary is installed and errors if not.
-var agentCycle = []agent.Type{agent.Claude, agent.Codex, agent.OpenCode}
+var agentCycle = []agent.Type{agent.Claude, agent.Codex, agent.OpenCode, agent.Cursor}
// cycleAgent advances the selected agent by delta (+1 next, -1 prev), wrapping.
func (d *SessionCreateDialog) cycleAgent(delta int) {
diff --git a/internal/ui/settings.go b/internal/ui/settings.go
index bdb9ceb3..3e8f585d 100644
--- a/internal/ui/settings.go
+++ b/internal/ui/settings.go
@@ -29,7 +29,7 @@ var (
chevronStyleSet = []string{"triangle", "plusminus"}
densitySet = []string{"normal", "compact"}
enterModeSet = []string{"attach", "split"}
- defaultAgentSet = []string{"claude", "codex", "opencode"}
+ defaultAgentSet = []string{"claude", "codex", "opencode", "cursor"}
telemetryModeSet = []string{config.TelemetryFull, config.TelemetryMinimal, config.TelemetryOff}
suspendModeSet = []string{config.SuspendOff, config.SuspendLight, config.SuspendBalanced, config.SuspendAggressive}
)
@@ -685,11 +685,13 @@ func buildSettingsCategories() []settingsCategory {
return "Codex"
case "opencode":
return "OpenCode"
+ case "cursor":
+ return "Cursor"
default:
return "Claude"
}
},
- valueW: func() int { return maxStrW([]string{"Claude", "Codex", "OpenCode"}) },
+ valueW: func() int { return maxStrW([]string{"Claude", "Codex", "OpenCode", "Cursor"}) },
cycle: func(d *SettingsDialog, dir int) {
d.cfg.DefaultAgent = cycleString(d.cfg.GetDefaultAgent(), defaultAgentSet, dir)
},
diff --git a/internal/ui/sidebar.go b/internal/ui/sidebar.go
index c7c3a7af..dea78950 100644
--- a/internal/ui/sidebar.go
+++ b/internal/ui/sidebar.go
@@ -750,15 +750,17 @@ func renderSessionItem(item SidebarItem, width int, selected bool, slot int) str
// carried by shape alone: the status dot keeps the (dynamic) status color, so
// the glyph is always rendered muted via AgentGlyphStyle regardless of status.
const (
- // Both glyphs are width-1 and live in well-covered Unicode blocks so they
+ // All glyphs are width-1 and live in well-covered Unicode blocks so they
// render cleanly in base monospace fonts (Menlo/SF Mono) and stay aligned:
- // ✻ is Dingbats; ◇ and △ are Geometric Shapes — the same block as the status
- // dots (●○◐). A hexagon (U+2B21) was tried first but falls back to a wider
- // glyph in those fonts, shifting the title. △ is a clean third shape, distinct
- // from the star and the diamond.
+ // ✻ and ✦ are Dingbats; ◇ and △ are Geometric Shapes — the same block as the
+ // status dots (●○◐). A hexagon (U+2B21) was tried first but falls back to a
+ // wider glyph in those fonts, shifting the title. △ is a clean third shape,
+ // distinct from the star and the diamond; ✦ (four-pointed star) is a clean
+ // fourth, distinct from ✻'s six-pointed asterisk.
claudeGlyph = "✻"
codexGlyph = "◇"
opencodeGlyph = "△"
+ cursorGlyph = "✦"
)
// agentGlyph returns the sigil for a session's agent. An empty or unrecognized
@@ -769,6 +771,8 @@ func agentGlyph(t agent.Type) string {
return codexGlyph
case agent.OpenCode:
return opencodeGlyph
+ case agent.Cursor:
+ return cursorGlyph
default:
return claudeGlyph
}
diff --git a/internal/ui/sidebar_test.go b/internal/ui/sidebar_test.go
index e3b027bd..95f3d775 100644
--- a/internal/ui/sidebar_test.go
+++ b/internal/ui/sidebar_test.go
@@ -115,6 +115,7 @@ func TestRenderSessionItem_AgentGlyph(t *testing.T) {
{"claude", agent.Claude, claudeGlyph, codexGlyph},
{"codex", agent.Codex, codexGlyph, claudeGlyph},
{"opencode", agent.OpenCode, opencodeGlyph, claudeGlyph},
+ {"cursor", agent.Cursor, cursorGlyph, claudeGlyph},
// Empty agent (legacy sessions) falls back to Claude.
{"empty falls back to claude", "", claudeGlyph, codexGlyph},
}