Skip to content

feat(agent): add Cursor CLI as a supported agent - #213

Open
VainEyal wants to merge 30 commits into
brizzai:masterfrom
VainEyal:feat/cursor-agent
Open

feat(agent): add Cursor CLI as a supported agent#213
VainEyal wants to merge 30 commits into
brizzai:masterfrom
VainEyal:feat/cursor-agent

Conversation

@VainEyal

@VainEyal VainEyal commented Jul 21, 2026

Copy link
Copy Markdown

Summary

  • Adds Cursor CLI (cursor-agent) as a fourth session agent alongside Claude, Codex, and OpenCode: selectable from the A agent picker or as default_agent in config/Settings.
  • internal/agent: new Cursor type with binary name, display name, and launch/resume command (cursor-agent / cursor-agent --resume <id>). No fork primitive exists in cursor-agent, so fork-to-worktree stays Claude-only (existing guard covers it automatically).
  • internal/hooks/cursor_hooks.go (new): installs fleet's hook command into ~/.cursor/hooks.json, only when cursor-agent is on PATH. Cursor's hooks.json schema is flatter than Claude/Codex's nested {matcher, hooks:[...]} shape, so this is a small dedicated injector rather than a reuse of the Codex one — it does reuse the agent-neutral marker/path helpers (isFleetHook, GetHookCommand, FleetBinaryPath).
  • Status detection is pure hook-driven (same shape as OpenCode's branch in UpdateStatus) — no pane-scraping. Cursor has no dedicated permission/approval hook, so beforeShellExecution→waiting / afterShellExecution→running bracket the interactive approval prompt instead.
  • No dir-trust seeding needed (unlike Codex): Cursor's permission model is a global allowlist (~/.cursor/cli-config.json), not a per-directory trust flag.
  • No known local source for reading a Cursor chat's title, so naming falls back to fleet's prompt heuristic (same as OpenCode today).
  • Sidebar gets a new glyph for Cursor sessions; diagnostics reports cursor-agent --version.
  • CLAUDE.md and a changelog fragment (changelog/unreleased/cursor-agent.md, not flagged as a highlight) are updated accordingly.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Documentation
  • Other

Checklist

  • Tests pass (make test)
  • Linter passes (make lint) — golangci-lint isn't installed in the environment this was built in; go vet ./... and go fmt ./... are clean
  • Commit message follows conventional commits
  • Documentation updated (CLAUDE.md)
  • Changelog fragment added in changelog/unreleased/

Notes for reviewers

  • The beforeShellExecution/afterShellExecution → waiting/running status mapping is inferred from Cursor's public docs, not observed against a live approval prompt — worth a manual smoke test with a real cursor-agent session before merging.
  • Binary name is cursor-agent (confirmed locally; Cursor's installer also symlinks it as the more generic agent, which was deliberately avoided to reduce PATH-collision risk).

Summary by CodeRabbit

  • New Features
    • Added Cursor CLI as a selectable session agent with default-agent selection, sidebar labeling, and resume support.
    • Added automatic Cursor hook installation and updates while preserving existing settings.
    • Diagnostics now display the Cursor CLI version when detected.
  • Bug Fixes
    • Improved Cursor status tracking and idle-state handling when hooks are unavailable.
  • Documentation
    • Updated documentation to include Cursor support and capabilities.
  • Tests
    • Expanded coverage for Cursor sessions, status mapping, and hook installation.

Copilot AI lite review requested due to automatic review settings July 21, 2026 11:43
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "tools"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

Cursor is added as a selectable Fleet agent with cursor-agent launch support, Cursor hook installation and status mapping, session lifecycle handling, UI representation, diagnostics collection, and documentation updates.

Changes

Cursor agent support

Layer / File(s) Summary
Agent model and UI exposure
internal/agent/*, internal/config/config.go, internal/ui/*
Cursor is added to agent parsing, launch commands, default-agent settings, session creation, sidebar glyphs, and related tests.
Cursor hooks and status events
internal/hooks/*, cmd/fleet/hook_handler.go, internal/hooks/hook_watcher.go
Fleet installs and updates Cursor hooks idempotently, preserves valid user hooks, rejects malformed entries, and maps Cursor events to statuses and prompt counts.
Cursor session lifecycle
internal/session/*
Cursor sessions use idle and hook-only status behavior and do not rely on Claude transcript or stored title data.
UI wiring, diagnostics, and documentation
internal/ui/app.go, internal/diagnostics/diagnostics.go, README.md, CLAUDE.md, changelog/unreleased/cursor-agent.md
The UI installs Cursor hooks and restricts unsupported forks. Diagnostics report the Cursor CLI version. Documentation describes Cursor support and fork limitations.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FleetUI as Fleet UI
  participant CursorCLI as Cursor CLI
  participant HookHandler as Hook handler
  participant Session as Fleet session
  FleetUI->>CursorCLI: launch cursor-agent or resume session
  FleetUI->>CursorCLI: install or update hooks.json
  CursorCLI->>HookHandler: emit Cursor lifecycle event
  HookHandler->>Session: map event and update status
  HookHandler->>Session: increment prompt count on beforeSubmitPrompt
Loading

Possibly related PRs

  • brizzai/fleet#78: Introduces the shared agent and hook integration patterns extended here for Cursor.
  • brizzai/fleet#84: Relates to shared launch and fork handling, including Cursor’s unsupported fork behavior.
  • brizzai/fleet#134: Relates to hook-driven agent status handling extended here for Cursor.

Suggested reviewers: copilot, hayke102

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies adding Cursor CLI as a supported agent, which is the primary change in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

# Conflicts:
#	CLAUDE.md
#	internal/ui/app.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds Cursor CLI (cursor-agent) as a first-class session agent in fleet, integrating it into the agent model, UI selection surfaces, hook-based status pipeline, and diagnostics.

Changes:

  • Introduces agent.Cursor with launch/resume command support and UI glyph/display updates.
  • Adds a dedicated Cursor hooks injector (~/.cursor/hooks.json) and extends the hook handler to map Cursor hook events into fleet statuses.
  • Wires Cursor into settings/config defaults, session creation agent picker, naming fallback behavior, and diagnostics reporting.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/ui/sidebar.go Adds Cursor agent glyph () and maps agent.Cursor to the new glyph.
internal/ui/sidebar_test.go Extends glyph rendering test coverage to include Cursor.
internal/ui/settings.go Adds cursor to the default-agent settings cycle and display label/width logic.
internal/ui/session_create.go Includes Cursor in the agent picker cycle order.
internal/ui/app.go Injects Cursor hooks on startup when cursor-agent is present on PATH.
internal/session/session.go Treats Cursor as a hook-only agent path for status updates; adjusts initial status behavior/commentary.
internal/session/agent_name.go Adds Cursor to agent-title lookup (explicitly falls back to prompt heuristic).
internal/hooks/cursor_hooks.go New Cursor hooks injector/merger for Cursor’s flat hooks.json schema.
internal/hooks/cursor_hooks_test.go Adds tests for Cursor hook injection and preserving user hooks.
internal/diagnostics/diagnostics.go Captures and reports cursor-agent --version in diagnostics output.
internal/config/config.go Documents and normalizes default_agent: "cursor" in config parsing.
internal/agent/agent.go Adds Cursor agent type, binary name, display name, and resume launch command.
internal/agent/agent_test.go Extends agent parsing/launch command tests to include Cursor.
cmd/fleet/hook_handler.go Adds Cursor event→status mapping and treats beforeSubmitPrompt as a prompt submission for prompt counting.
CLAUDE.md Updates internal documentation to mention Cursor as a supported agent.
changelog/unreleased/cursor-agent.md Adds a changelog fragment announcing Cursor CLI support.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/hooks/cursor_hooks.go Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 11:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

internal/hooks/cursor_hooks.go:134

  • mergeCursorHookEvent drops all existing entries for an event when the existing JSON can’t be unmarshaled into []cursorHookEntry (entries=nil). That contradicts InjectCursorHooks’ “preserving any existing user hooks” contract and can silently clobber a user’s hooks for that event.
func mergeCursorHookEvent(existing json.RawMessage) json.RawMessage {
	var entries []cursorHookEntry
	if existing != nil {
		if err := json.Unmarshal(existing, &entries); err != nil {
			entries = nil
		}
	}

Comment thread internal/hooks/cursor_hooks_test.go Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 12:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

internal/hooks/cursor_hooks_test.go:50

  • The note about idempotency being untestable under go test is outdated: GetHookCommand() includes the binary-name-independent --fleet-hook marker, and isFleetHook matches it. It would be useful to assert that a second InjectCursorHooks() call returns changed=false to lock in idempotency.
	// Note: idempotency on re-install relies on the fleet-hook marker
	// ("fleet hook-handler") matching the launch command, which requires the
	// binary to be named "fleet" — true in production but not under `go test`
	// (binary is *.test), so we don't assert no-op re-install here.

Comment thread internal/hooks/cursor_hooks.go Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 12:14
@VainEyal
VainEyal marked this pull request as ready for review July 21, 2026 12:20
@VainEyal
VainEyal requested a review from hayke102 as a code owner July 21, 2026 12:20
@gitstream-cm

gitstream-cm Bot commented Jul 21, 2026

Copy link
Copy Markdown

🚨 gitStream Monthly Automation Limit Reached 🚨

Your organization has exceeded the number of pull requests allowed for automation with gitStream.
Monthly PRs automated: 251/250

To continue automating your PR workflows and unlock additional features, please contact LinearB.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Comment thread internal/session/session.go
Comment thread cmd/fleet/hook_handler.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Comment thread internal/hooks/cursor_hooks_test.go Outdated
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 22, 2026 07:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Comment thread internal/hooks/cursor_hooks.go
if err != nil {
return false, fmt.Errorf("marshal hooks: %w", err)
}
root["hooks"] = eventsRaw

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.

Nil-map panic that kills fleet at startup. If ~/.cursor/hooks.json holds a top-level null, json.Unmarshal at line 68 leaves root nil and returns no error — so the guard passes and this line panics with assignment to entry in nil map.

Reproduced:

os.WriteFile(dir+"/hooks.json", []byte("null"), 0644)
InjectCursorHooks(dir)
// panic: assignment to entry in nil map ... cursor_hooks.go:104

InjectCursorHooks runs from loadSessions' tea.Cmd goroutine, so Bubble Tea's panic handler restores the terminal and exits — fleet dies on launch with a stack trace and the user can't reach the TUI to fix the file.

Lines 84-89 already handle exactly this hazard one level down ("hooks": null), with a test. The else at line 67 needs the same treatment:

} else {
	if err := json.Unmarshal(orig, &root); err != nil {
		...
	}
	if root == nil {
		root = make(map[string]json.RawMessage)
	}
}

Comment thread cmd/fleet/hook_handler.go Outdated
// cursorHookEvents in internal/hooks/cursor_hooks.go).
case "beforeSubmitPrompt":
return "running"
case "beforeShellExecution":

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.

The comment above says "waiting is only wrong for auto-approved commands, which resolve to running again almost immediately" — that isn't right. afterShellExecution fires when the command completes, not when the approval clears. So an auto-approved npm test that takes 90s shows ◐ waiting for the full 90s while the agent is actually working. A denied command may never fire afterShellExecution at all, pinning it indefinitely.

And Cursor is on the pure-hook UpdateStatus branch (session.go:770) with no pane fallback, so nothing corrects it.

Two things consume that wrong state:

  • Space rotates to it as if it needs attention.
  • quickApproveSelected (app.go:4243) gates only on StatusWaiting + IsAlive()no agent gate. Pressing Y sends y then Enter. With no approval prompt on screen, that y is submitted as a new user message mid-turn.

The PR description is candid that this mapping was inferred from docs rather than observed against a live prompt. Given the Y blast radius, I'd want it observed before merge — and if Cursor really has no distinguishable approval signal, mapping beforeShellExecutionrunning is the safe default (loses the waiting state, never fabricates one).

Comment thread cmd/fleet/hook_handler.go
return "waiting"
case "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.

Comment thread cmd/fleet/hook_handler.go
return "running"
case "stop":
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.

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

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.

This branch force-sets StatusIdle on every tick when hasHook is false — fine for OpenCode, but the failure mode is quieter for Cursor because there are more ways to end up with no hook file:

  • hooks are injected only from loadSessions at startup (app.go:6670), so installing cursor-agent while fleet is running leaves the session hookless for the rest of the launch;
  • if InjectCursorHooks returns an error, app.go:6672 only debuglog.Logger.Errors it — nothing reaches the UI;
  • handleSessionCreate checks only exec.LookPath("cursor-agent"), so session creation still succeeds.

The user then gets a row that resets to ○ idle every 500ms forever: never Running, never Waiting on an approval, invisible to Space and the status pills. No error explains why — unlike a missing claude CLI, which surfaces as a warning in loadSessionsMsg.

Surfacing the injection error as a warning (same as the claude path) would make this diagnosable.

Comment thread internal/ui/app.go
}
// Install Cursor CLI hooks too, but only if cursor-agent is present — never
// create ~/.cursor for users who don't have it.
if _, err := exec.LookPath("cursor-agent"); err == nil {

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.

This installs into Cursor's global ~/.cursor/hooks.json — which the Cursor IDE also reads, not just cursor-agent. And having the IDE installed is exactly what puts cursor-agent on PATH, so the LookPath guard selects for the affected users rather than against them.

Every agent action in the GUI then forks fleet hook-handler, which hits the instanceID == "" branch (hook_handler.go:140) and emits log.Warn("hook-handler: no FLEET_INSTANCE_ID env var", ...). Warn is above the default level, so it always writes.

Two consequences:

  1. debug.log is size-truncated, so a normal editing session evicts fleet's own history — and the ! bug-report flow pastes the last 100 lines into a public GitHub issue. Reports would ship 100 lines of no FLEET_INSTANCE_ID instead of diagnostics.
  2. beforeShellExecution is a blocking hook, so every terminal command in the IDE waits on a fleet process spawn.

At minimum the no-instance-id path should drop to Debug. Better: check whether Cursor supports a CLI-scoped hooks file, or bail out early when FLEET_INSTANCE_ID is unset before doing any work.

Comment thread internal/hooks/cursor_hooks.go
Comment thread internal/hooks/cursor_hooks.go Outdated
for i, raw := range entries {
var probe cursorHookEntryProbe
if err := json.Unmarshal(raw, &probe); err != nil {
continue // not a command-hook object (e.g. a prompt hook) — leave it untouched

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.

The comment describes a branch that isn't reached. A prompt-hook entry ({"type":"prompt","prompt":"..."}) unmarshals into cursorHookEntryProbe without errorCommand just decodes to "" — so it exits via !isFleetHook(probe.Command) on line 194, not this continue. Coverage confirms line 192 is never hit by TestInjectCursorHooksPreservesUnknownFieldsAndPromptHooks, which is the test that seeds one.

The test passing makes the comment look load-bearing. A maintainer reads "not a command-hook object (e.g. a prompt hook) — leave it untouched", concludes prompt hooks are already filtered here, and treats the line-194 guard as only about other users' command hooks — the natural next simplification. Drop or tighten it and the loop claims the first entry of any event as fleet's own and patches its command key, rewriting a user's prompt hook into {"type":"prompt","prompt":"...","command":"'/path/fleet' hook-handler --fleet-hook"}. The existing test still passes (entry count and prompt field both survive).

Either reword to describe what actually happens, or make the probe genuinely reject non-command entries (e.g. required-field check) so the comment becomes true.

Comment thread CLAUDE.md Outdated
- Extension commands: `open_or_focus`, `close_tab`, `create_tab_group`, `ping`
- Service worker reconnects to native host on disconnect (2s delay)
- Claude Code + OpenAI Codex + OpenCode, macOS + Linux (see `docs/linux.md` for the platform split)
- Claude Code + OpenAI Codex + OpenCode + Cursor CLI, macOS + Linux (see `docs/linux.md` for the platform split)

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.

CLAUDE.md is updated in five places for Cursor, but README.md still says three agents in ten — and it's the one users actually see:

  • L8 tagline: "orchestrating Claude Code, Codex & OpenCode sessions"
  • L85 requirements: the three CLIs as the "at least one"
  • L95: # 'A' — new session, pick the agent (Claude Code / Codex / OpenCode)
  • L102/104 feature heading + body, L134 resume list, L146, L159 comparison row ("✅ Claude + Codex + OpenCode"), L164, L166
  • L178 keybindings table: A → "New session (pick agent: Claude Code / Codex / OpenCode)"

A Cursor CLI user reading the GitHub page concludes fleet doesn't support their agent; a user who presses A sees a fourth option the docs never mention. The OpenCode PR updated README in lockstep with CLAUDE.md, so this is drift introduced here rather than pre-existing.

hayke102 and others added 4 commits August 2, 2026 10:58
A hooks.json holding a bare `null` unmarshals into a nil root map without
returning an error, so the parse guard let it through and the
root["hooks"] assignment panicked. InjectCursorHooks runs from
loadSessions' tea.Cmd, so this took the TUI down at startup with a stack
trace — leaving no way to reach the UI and fix the file.

Initialize the root map the same way the events map one level down
already does, and cover both with regression tests. Also adds the
stale-fleet-path migration test the Claude twin has but this didn't: that
patch branch runs on every install whose binary path changed, and a
regression there would append a duplicate entry on each launch.

Rewords the `continue` comment in mergeCursorHookEvent, which described a
branch it doesn't reach — prompt hooks unmarshal cleanly into the probe
with an empty Command and exit at the isFleetHook check below instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
Verified against the shipped cursor-agent bundle (2026.07.23-e383d2b):

afterShellExecution's payload carries `output` and `duration`, so it
fires when the command *completes*, not when an approval clears. Mapping
beforeShellExecution to waiting therefore held the row at ◐ for the
entire runtime of every command the agent ran — and Cursor is on the
pure-hook path with no pane fallback to correct it. `Space` rotated to
busy sessions, and `Y` quick-approve (which gates only on StatusWaiting)
would send "y"+Enter into a pane showing no prompt, injecting a stray
message mid-turn. Cursor exposes no approval hook at all, so both shell
hooks now map to running and fleet simply has no waiting signal for it.

The stop hook carries status: "completed" | "aborted" | "error" (three
literal call sites in the bundle). All three mapped to finished, so a
turn that died on a model error rendered with the same "done, come look"
dot as a successful one. Now only errors surface as errors; aborted is a
user-initiated cancel, so it stays finished.

Also widens emitHookMetrics, which matched only Claude's PascalCase
event names — prompt/response volume and the first-response onboarding
milestone were silently dropped for OpenCode and would have been for
Cursor, registering those users as never having gotten a response.

And drops the missing-FLEET_INSTANCE_ID log from Warn to Debug: fleet
installs into the user-global ~/.cursor/hooks.json, which the Cursor IDE
reads too, so this fired for every editor action. debug.log is
size-truncated and the `!` bug-report flow pastes its last 100 lines into
a public issue, so an editing session would evict the actual diagnostics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
Cursor has no fork primitive, so its BuildLaunchCmd branch dropped
LaunchOpts.ForkID — but `f` / "Fork Session" gated only on a captured
session id, not the agent. Since Cursor's hook payload does carry
session_id, that path was live: forking a Cursor session launched a bare
cursor-agent, producing a row titled "<title> (fork)" holding an empty
conversation with no error. That contradicts the documented invariant
that a lit context-menu row can never dead-click.

Adds Type.SupportsFork() so the capability lives next to the launch
commands that implement it, and gates both forkSelected and the context
menu on it — the row now dims with "Cursor has no fork" rather than
lying. TestSupportsFork ties the two together: any agent claiming
support must actually change its command when given a ForkID.

Separately, a failed InjectCursorHooks was only logged. Cursor rides the
pure-hook status path, so with no hooks.json every session resets to idle
on each tick — never running, never waiting — with nothing on screen
explaining why. It now surfaces as a startup warning like a missing
claude CLI does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
CLAUDE.md was updated for Cursor in five places but README.md still said
three agents in eleven — the tagline, requirements, feature section,
comparison table, and keybindings table. A Cursor CLI user reading the
GitHub page would conclude fleet doesn't support their agent, and anyone
pressing `A` would see a fourth option the docs never mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
Copilot AI review requested due to automatic review settings August 2, 2026 07:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/hooks/hook_watcher.go (1)

268-276: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Exclude failed Cursor turns from response-success metrics.

A Cursor stop event with Status == "error" reaches this branch. cmd/fleet/hook_handler.go Lines 94-95 set that status for a failed turn. This code then records a response and can mark the first-response onboarding milestone.

Skip response-success metrics when curr.Status == "error". Add a regression test for a Cursor stop error.

Proposed fix
 case "Stop", "stop", "session.idle":
+	if curr.Status == "error" {
+		return
+	}
 	if prev == nil || !isResponseEnd(prev.Event) || !prev.UpdatedAt.Equal(curr.UpdatedAt) {
 		analytics.Track(analytics.EventClaudeResponseReceived, nil)
🤖 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 `@internal/hooks/hook_watcher.go` around lines 268 - 276, Update the "Stop",
"stop", and "session.idle" handling branch in the hook watcher to skip
response-success analytics when curr.Status equals "error", including both
response and first-response onboarding milestone tracking. Preserve existing
tracking behavior for non-error events, and add a regression test covering a
Cursor stop event with error status.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@README.md`:
- Around line 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.

---

Outside diff comments:
In `@internal/hooks/hook_watcher.go`:
- Around line 268-276: Update the "Stop", "stop", and "session.idle" handling
branch in the hook watcher to skip response-success analytics when curr.Status
equals "error", including both response and first-response onboarding milestone
tracking. Preserve existing tracking behavior for non-error events, and add a
regression test covering a Cursor stop event with error status.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f973d58-918a-4ac8-81cc-9e82d1e29faf

📥 Commits

Reviewing files that changed from the base of the PR and between 835f0cf and fe2d5d4.

📒 Files selected for processing (9)
  • README.md
  • cmd/fleet/hook_handler.go
  • cmd/fleet/hook_handler_test.go
  • internal/agent/agent.go
  • internal/agent/agent_test.go
  • internal/hooks/cursor_hooks.go
  • internal/hooks/cursor_hooks_test.go
  • internal/hooks/hook_watcher.go
  • internal/ui/app.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/hooks/cursor_hooks.go
  • internal/ui/app.go

Comment thread README.md
Comment on lines +102 to +104
### 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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (3)

README.md:104

  • The README claims status across all four agents is driven by hook events with pane heuristics filling no-hook states, but Cursor (and OpenCode) are hook-only with no pane fallback. This makes the sentence inaccurate and could mislead users debugging why a Cursor session stays idle when hooks aren’t installed.
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.

CLAUDE.md:138

  • This Cursor status mapping documentation contradicts the actual Cursor event mapping in cmd/fleet/hook_handler.go: beforeShellExecution/afterShellExecution are both mapped to running (and the comment explicitly says Cursor has no waiting signal). The docs currently claim beforeShellExecution→waiting, which would lead users to the wrong mental model when triaging status behavior.
- 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.

internal/ui/app.go:3836

  • The fork action now gates on SupportsFork() and can be enabled for non-Claude agents, but this error message still says “Claude conversation ID”. Since ClaudeSessionID is used as the generic resume/session ID for multiple agents in this codebase, the message is misleading for Codex/OpenCode users.
	if s.ClaudeSessionID == "" {
		h.setError(fmt.Errorf("cannot fork: session has no Claude conversation ID yet"))
		return nil

Captured from a live cursor-agent run (2026.07.23-e383d2b), the sessionEnd
payload is:

  {reason: "completed", final_status, duration_ms, session_id,
   conversation_id, transcript_path, is_background_agent, ...}

It fires when a *conversation* ends, with the process alive and sitting at
its prompt. Mapping it to "dead" put a healthy session into StatusError,
wrote a spurious crash dump, and made it eligible for Reload All Sessions
— which restarts dead/error sessions, killing a live agent.

Cursor's actual process death is already covered by tmux pane-death, the
same way Codex and OpenCode handle it; neither maps an end-of-session hook
to dead either.

The same capture confirms two things the earlier commits relied on: the
payload carries session_id and conversation_id with identical values (so
resume IDs are captured), and afterShellExecution carries a `duration`
(1171.189ms on a real command), confirming it fires at completion rather
than on approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
Copilot AI review requested due to automatic review settings August 2, 2026 13:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 2, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@hayke102

hayke102 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@VainEyal heads up — I pushed 6 commits to this branch (maintainer edits), so please review them before merging. I'd rather you sign off than have me merge my own changes into your PR.

To validate the review I installed cursor-agent (2026.07.23-e383d2b), read the shipped bundle, and captured live hook payloads. That changed several conclusions — including two of my own review comments, which were wrong.

Two of my review comments were wrong — retracted

  • session_id vs conversation_id. I claimed Cursor keys the thread as conversation_id and that --resume was therefore dead code. Wrong. executeHookForStep spreads the call-site object (which carries conversation_id) and then adds session_id: session_id ?? conversation_id. A live capture confirms both keys present with identical values. Your comment was correct.
  • Shell quoting. I worried GetHookCommand()'s single-quoted path would break if Cursor execFile'd the command argv-split. It doesn't — executeCommandScript builds `${command} <<'CURSOR_HOOK_EOF'…` , a heredoc, which only works under a shell.

Both threads are retracted and resolved. Sorry for the noise.

What the 6 commits change

Commit
56ffad8 Startup panic. A hooks.json holding a bare null unmarshals to a nil root map without erroring, so root["hooks"] = … panicked — and since InjectCursorHooks runs from loadSessions, that killed the TUI at launch with no way back in. Same guard the events map already had. Plus the stale-path migration test and a comment fix.
e5e2423 Shell hooks → running, never waiting. afterShellExecution's payload carries output and duration (measured 1171ms on a real command), so it fires at completion, not when an approval clears — waiting held the row at ◐ for the whole command. With no pane fallback, Space rotated to busy sessions and Y would send y+Enter into a pane showing no prompt. stop.status is now read (completed/aborted/error — three literal call sites in the bundle); previously a failed turn rendered identically to a successful one. Also widened emitHookMetrics, which matched only Claude's PascalCase names, and dropped the missing-FLEET_INSTANCE_ID log to Debug.
4e5aeb3 Fork gate. f checked only for a session id, not the agent, so forking a Cursor session dropped ForkID and launched an empty conversation. Adds Type.SupportsFork() and gates forkSelected + the context menu on it. Also surfaces a failed hook install as a startup warning — otherwise every Cursor session silently pins to idle forever.
fe2d5d4 README's eleven "three agents" mentions.
e9216b4 sessionEnd is not a death. Captured payload: {reason: "completed", final_status, duration_ms, …} with the process alive at its prompt. Mapping it to dead set StatusError, wrote a crash dump, and made the session eligible for Reload All Sessions — which restarts error sessions, killing a live agent. Now finished; real death comes from tmux pane-death, same as Codex/OpenCode.
1423ddd Merge master (you were 7 behind). One conflict in forkSelected — my new guard vs #229's switch to the mutex-guarded GetClaudeSessionID(). Kept both.

go build ./..., gofmt, and go test ./... all clean on the merge result.

One thing I did NOT fix — worth your call

Cursor CLI appears to have a per-directory trust gate, which contradicts the comment in cursor_hooks.go:

no dir-trust seeding (Cursor's permission model is a global allowlist in ~/.cursor/cli-config.json, not a per-directory trust flag)

Launching cursor-agent in a fresh directory gave me:

Cursor Agent can execute code and access files in this directory.
Do you trust the contents of this directory?
  • Run 'agent' interactively to decide
  • Pass --trust, --yolo, or -f if you trust this directory

The bundle writes a trust marker ({trustedAt, workspacePath, trustMethod: "cli-flag"}) via an agent-store path, but I couldn't find where it persists on disk, and my follow-up probes were muddied by --print mode denying shell execution for unrelated reasons — so I stopped rather than guess.

Why it matters here: fleet creates a new worktree per session, so each one is a fresh directory. If that prompt appears, the session sits blocked on it — and because Cursor rides the pure-hook path (and I just removed the only waiting mapping), fleet will render it as idle rather than needing attention. That's arguably worse than the bug I fixed.

Needs one interactive run in a real tmux pane to settle. If it's real, the fix is probably an EnsureCursorDirTrust mirroring EnsureCodexDirTrust, or passing --trust on launch for fleet-created worktrees (which derive from an already-trusted repo).

Also worth knowing

Hooks install into the user-global ~/.cursor/hooks.json, which the Cursor IDE reads too — so fleet's handler is armed for every editor action, not just fleet sessions. Dropping the log to Debug stops that from poisoning debug.log (which the ! bug-report flow pastes into public issues), but the per-action process spawn is inherent to a shared config file. Might be worth checking whether Cursor supports a CLI-scoped hooks path.

Copilot AI review requested due to automatic review settings August 4, 2026 08:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants