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}, }