Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ae1d310
feat(agent): add Cursor as a supported agent type
Jul 20, 2026
505cdf6
feat(hooks): install fleet hooks into Cursor CLI's hooks.json
Jul 20, 2026
3312949
feat(hooks): map Cursor CLI hook events to fleet status
Jul 20, 2026
435ee66
feat(session): wire Cursor into status detection and naming
Jul 20, 2026
fb570cf
feat(config): support cursor as a default agent value
Jul 20, 2026
d3111e1
feat(ui): add Cursor to agent picker, sidebar glyph, and settings
Jul 20, 2026
63153b9
feat(ui): install Cursor CLI hooks at startup when present
Jul 20, 2026
29f472b
feat(diagnostics): report cursor-agent version
Jul 20, 2026
e8653e3
docs: document Cursor CLI agent support
Jul 20, 2026
9c8eebf
docs: add changelog fragment for Cursor CLI support
Jul 20, 2026
5e76a80
fix(agent): wire the missing Cursor branch into BuildLaunchCmd
Jul 20, 2026
bedefb8
Merge remote-tracking branch 'origin/master' into feat/cursor-agent
Jul 21, 2026
6f8d0fe
fix(hooks): fail closed on unparseable Cursor hook event entries
Jul 21, 2026
7e3e2a6
fix(hooks): use a unique temp file for Cursor hooks.json writes
Jul 21, 2026
16b02ba
fix(hooks): stop Cursor's sessionStart from forcing finished on launch
Jul 21, 2026
98e10de
fix(hooks): preserve unknown fields when merging Cursor hook entries
Jul 21, 2026
c5edf50
fix(hooks): don't overwrite an existing hooks.json version field
Jul 21, 2026
81229d6
docs(cursor): mention plain cursor-agent launch form, fix changelog v…
Jul 21, 2026
da96ae7
fix(hooks): correct stale byte-for-byte claim in cursor hooks test co…
Jul 21, 2026
1a0a612
fix(hooks): treat a null cursor hooks section as empty to avoid nil-m…
Jul 21, 2026
bbe60ea
fix(hooks): check os.ReadFile errors in cursor hooks tests
Jul 21, 2026
2b3d694
fix(hooks): correct misleading seed-comment in cursor hooks test
Jul 22, 2026
835f0cf
Merge remote-tracking branch 'origin/master' into feat/cursor-agent
Jul 28, 2026
56ffad8
fix(hooks): don't panic on a null ~/.cursor/hooks.json
hayke102 Aug 2, 2026
e5e2423
fix(cursor): stop pinning sessions to waiting, and read stop status
hayke102 Aug 2, 2026
4e5aeb3
fix(cursor): gate fork on agent support, surface hook-install failure
hayke102 Aug 2, 2026
fe2d5d4
docs: add Cursor to the README agent lists
hayke102 Aug 2, 2026
e9216b4
fix(cursor): sessionEnd is a conversation end, not a process death
hayke102 Aug 2, 2026
1423ddd
Merge remote-tracking branch 'origin/master' into pr-213
hayke102 Aug 2, 2026
30a8f13
Merge remote-tracking branch 'origin/master' into pr-213
hayke102 Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` / `codex fork <id>` / `opencode --session <id>` / `opencode --session <id> --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":{"<event>":[{"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."<path>"] 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 <id>` / `codex resume <id>` 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 <App>` 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.
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<strong>Run 10 coding agents. Stay sane.</strong>
</p>
<p align="center">
A terminal cockpit for orchestrating Claude Code, Codex &amp; OpenCode sessions in parallel.
A terminal cockpit for orchestrating Claude Code, Codex, OpenCode &amp; Cursor sessions in parallel.
<br />
See which agents need you. Jump in, direct, jump out.
</p>
Expand Down Expand Up @@ -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
Expand All @@ -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.
Comment on lines +102 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the Cursor status and naming claims.

This paragraph says that all four agents have identical status and auto-naming behavior and that pane heuristics fill missing hook states. Cursor uses a hook-only status path with no pane fallback. Cursor naming uses Fleet's prompt heuristic because Cursor has no local chat-title retrieval. Update the text to describe these Cursor-specific differences.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 102 - 104, Update the README paragraph describing
Claude, Codex, OpenCode, and Cursor to qualify the status behavior: Cursor uses
hooks only and has no pane-heuristic fallback. Also clarify that Cursor
auto-naming uses Fleet’s prompt heuristic because it cannot retrieve local chat
titles, rather than claiming identical status and naming behavior across all
agents.


### Real-Time Status

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/cursor-agent.md
Original file line number Diff line number Diff line change
@@ -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`.
72 changes: 65 additions & 7 deletions cmd/fleet/hook_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor's stop payload carries status: "completed" | "aborted" | "error", but this maps all three to finished unconditionally — no field of the payload is read.

So a turn that died on a model error or was aborted renders with the ● "done, come look" dot, and Space presents it alongside genuinely completed work. The user discovers it failed only after attaching.

OpenCode's adapter already distinguishes this (session.error"error"), and applyHookStatus has a case "error" arm ready — it's just unreachable for Cursor. Reading status here would wire it up.

// 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":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionEnd"dead" lands in applyHookStatus's case "dead" (session.go:857 / 922), which sets StatusError unconditionally and triggers a crash dump — with no pane corroboration.

But Cursor fires sessionEnd when a conversation ends (reason completed / aborted / user_close), which isn't the same as the process exiting. The tmux liveness checks above have already passed at that point, so the pane is alive and sitting at its prompt.

Result: a healthy session shows a red ✕ error until the next prompt, writes a spurious crash dump, and gets swept into Reload All Sessions — which restarts dead/error sessions, killing a live agent.

OpenCode is the only other agent on this branch and never emits "dead", so this arm was effectively unreachable before now. If Cursor's sessionEnd carries a reason, gate on it; otherwise a pane-death check before StatusError would be safer.

// 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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPromptSubmit covers the two call sites in this file, but emitHookMetrics (internal/hooks/hook_watcher.go:258) still switches on the raw event and matches only the PascalCase "UserPromptSubmit" / "Stop".

So for a Cursor session, EventClaudePromptSubmitted, EventClaudeResponseReceived and MarkOnboardingMilestone(MilestoneFirstClaudeResponse) never fire — a Cursor-only user registers in the dashboard as having installed fleet and never gotten a single agent response, which corrupts the activation funnel.

To be fair this gap is pre-existing — OpenCode's session.status/session.idle miss it too, so it's not introduced here. But this PR widens it, and now that isPromptSubmit exists the fix is nearly free: reuse it for the prompt arm and add the response-side names alongside "Stop".

}

// 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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -165,7 +223,7 @@ func handleHookHandler() {
}

// Increment prompt count on new user prompt submissions.
if payload.HookEventName == "UserPromptSubmit" {
if promptSubmit {
promptCount++
}

Expand Down
Loading