From ae1d3101339395d4260919513c6747c200bdb21b Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:01:16 +0300
Subject: [PATCH 01/26] feat(agent): add Cursor as a supported agent type
---
internal/agent/agent.go | 19 ++++++++++++++++---
internal/agent/agent_test.go | 11 +++++++----
2 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 9ec073ad..98eea824 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"
}
@@ -88,6 +95,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 {
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 4d294326..7689df7c 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,8 @@ 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"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -47,10 +50,10 @@ func TestBuildLaunchCmd(t *testing.T) {
}
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())
}
}
From 505cdf69849bc4111e85a3e642dd102b7c7817d0 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:01:16 +0300
Subject: [PATCH 02/26] feat(hooks): install fleet hooks into Cursor CLI's
hooks.json
---
internal/hooks/cursor_hooks.go | 151 ++++++++++++++++++++++++++++
internal/hooks/cursor_hooks_test.go | 73 ++++++++++++++
2 files changed, 224 insertions(+)
create mode 100644 internal/hooks/cursor_hooks.go
create mode 100644 internal/hooks/cursor_hooks_test.go
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
new file mode 100644
index 00000000..a45af9bd
--- /dev/null
+++ b/internal/hooks/cursor_hooks.go
@@ -0,0 +1,151 @@
+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.
+var cursorHookEvents = []string{
+ "sessionStart",
+ "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)
+ }
+ }
+
+ 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)
+ }
+ } else {
+ events = make(map[string]json.RawMessage)
+ }
+
+ for _, event := range cursorHookEvents {
+ events[event] = mergeCursorHookEvent(events[event])
+ }
+
+ eventsRaw, err := json.Marshal(events)
+ if err != nil {
+ return false, fmt.Errorf("marshal hooks: %w", err)
+ }
+ root["hooks"] = eventsRaw
+
+ 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)
+ }
+ tmpPath := hooksPath + ".tmp"
+ if err := os.WriteFile(tmpPath, finalData, 0644); err != nil {
+ return false, fmt.Errorf("write hooks.json.tmp: %w", err)
+ }
+ if err := os.Rename(tmpPath, hooksPath); err != nil {
+ os.Remove(tmpPath)
+ 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
+}
+
+// mergeCursorHookEvent adds fleet's hook to an event's flat entry array,
+// preserving any existing (non-fleet) entries and updating the command path
+// in place if it changed (e.g. after a rebuild).
+func mergeCursorHookEvent(existing json.RawMessage) json.RawMessage {
+ var entries []cursorHookEntry
+ if existing != nil {
+ if err := json.Unmarshal(existing, &entries); err != nil {
+ entries = nil
+ }
+ }
+
+ currentCmd := GetHookCommand()
+
+ for i, e := range entries {
+ if isFleetHook(e.Command) {
+ if e.Command != currentCmd {
+ entries[i].Command = currentCmd
+ }
+ result, _ := json.Marshal(entries)
+ return result
+ }
+ }
+
+ entries = append(entries, cursorHookEntry{Command: currentCmd, Type: "command"})
+ result, _ := json.Marshal(entries)
+ return result
+}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
new file mode 100644
index 00000000..39539b57
--- /dev/null
+++ b/internal/hooks/cursor_hooks_test.go
@@ -0,0 +1,73 @@
+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)
+ }
+ }
+ // 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.
+}
+
+func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
+ dir := t.TempDir()
+ // Pre-existing user hook on an event we don't manage + on one we do.
+ 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, _ := os.ReadFile(filepath.Join(dir, "hooks.json"))
+ 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), "sessionStart") {
+ t.Errorf("fleet event not added:\n%s", data)
+ }
+}
From 33129495cc277277983da61fd130ba73abea2d00 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:02:02 +0300
Subject: [PATCH 03/26] feat(hooks): map Cursor CLI hook events to fleet status
---
cmd/fleet/hook_handler.go | 33 ++++++++++++++++++++++++++++-----
1 file changed, 28 insertions(+), 5 deletions(-)
diff --git a/cmd/fleet/hook_handler.go b/cmd/fleet/hook_handler.go
index 3b89c08f..3e7db3a2 100644
--- a/cmd/fleet/hook_handler.go
+++ b/cmd/fleet/hook_handler.go
@@ -25,8 +25,9 @@ type hookPayload struct {
// 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.
+// 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 string) string {
switch event {
case "UserPromptSubmit":
@@ -62,6 +63,26 @@ 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).
+ // Cursor has no dedicated permission/approval hook, so beforeShellExecution/
+ // afterShellExecution bracket the interactive approval prompt instead: the
+ // hook fires and returns immediately, then (unless auto-approved) Cursor's
+ // own UI blocks on a y/n prompt before the command actually runs and
+ // afterShellExecution fires — so "waiting" is only wrong for auto-approved
+ // commands, which resolve to "running" again almost immediately.
+ case "sessionStart":
+ // At rest until a prompt is submitted, same as Claude's SessionStart.
+ return "finished"
+ case "beforeSubmitPrompt":
+ return "running"
+ case "beforeShellExecution":
+ return "waiting"
+ case "afterShellExecution":
+ return "running"
+ case "stop":
+ return "finished"
+ case "sessionEnd":
+ return "dead"
default:
return ""
}
@@ -147,10 +168,12 @@ func handleHookHandler() {
"claudeSession", payload.SessionID,
)
- // Extract user prompt and prompt count.
+ // Extract user prompt and prompt count. beforeSubmitPrompt is Cursor's
+ // UserPromptSubmit equivalent (see internal/hooks/cursor_hooks.go).
+ isPromptSubmit := payload.HookEventName == "UserPromptSubmit" || payload.HookEventName == "beforeSubmitPrompt"
var userPrompt string
var promptCount int
- if payload.HookEventName == "UserPromptSubmit" && payload.Prompt != "" {
+ if isPromptSubmit && payload.Prompt != "" {
userPrompt = payload.Prompt
}
@@ -165,7 +188,7 @@ func handleHookHandler() {
}
// Increment prompt count on new user prompt submissions.
- if payload.HookEventName == "UserPromptSubmit" {
+ if isPromptSubmit {
promptCount++
}
From 435ee66fbc1fbaf52ec98a5cc363ac57769fe814 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:02:52 +0300
Subject: [PATCH 04/26] feat(session): wire Cursor into status detection and
naming
---
internal/session/agent_name.go | 4 ++++
internal/session/session.go | 21 +++++++++++++--------
2 files changed, 17 insertions(+), 8 deletions(-)
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 8433ac9e..2661869c 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -141,10 +141,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
@@ -233,8 +233,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()
@@ -250,7 +250,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
}
@@ -730,11 +730,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
}
From fb570cf7ac2e84fd22c23c19bb03e1fcae354f0a Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:03:09 +0300
Subject: [PATCH 05/26] feat(config): support cursor as a default agent value
---
internal/config/config.go | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
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"
}
From d3111e1625e33f4231d624f0a544452494c58c38 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:04:25 +0300
Subject: [PATCH 06/26] feat(ui): add Cursor to agent picker, sidebar glyph,
and settings
---
internal/ui/session_create.go | 2 +-
internal/ui/settings.go | 6 ++++--
internal/ui/sidebar.go | 14 +++++++++-----
internal/ui/sidebar_test.go | 1 +
4 files changed, 15 insertions(+), 8 deletions(-)
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 5e8ffbd0..bd8eba67 100644
--- a/internal/ui/sidebar.go
+++ b/internal/ui/sidebar.go
@@ -646,15 +646,17 @@ func renderSessionItem(s *session.Session, width int, selected bool, slot int) s
// 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
@@ -665,6 +667,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 65b80574..f47ace7e 100644
--- a/internal/ui/sidebar_test.go
+++ b/internal/ui/sidebar_test.go
@@ -112,6 +112,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},
}
From 63153b95e70cafa13e18a066a70d0d2491b53499 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:04:48 +0300
Subject: [PATCH 07/26] feat(ui): install Cursor CLI hooks at startup when
present
---
internal/ui/app.go | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/internal/ui/app.go b/internal/ui/app.go
index 6fde0f08..38ea78bb 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -6237,6 +6237,13 @@ 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.
+ 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)
+ }
+ }
// Route tmux copy-mode selections to the macOS clipboard via pbcopy, so
// drag/click-to-copy works on terminals that block OSC 52 (iTerm2 default)
// or don't support it (Apple Terminal). Runs here for users with existing
From 29f472bbd1c8e99663fe48cded0bb6cc6de56659 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:05:09 +0300
Subject: [PATCH 08/26] feat(diagnostics): report cursor-agent version
---
internal/diagnostics/diagnostics.go | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/internal/diagnostics/diagnostics.go b/internal/diagnostics/diagnostics.go
index 71afd207..54294205 100644
--- a/internal/diagnostics/diagnostics.go
+++ b/internal/diagnostics/diagnostics.go
@@ -22,6 +22,7 @@ type Report struct {
TmuxVersion string
ClaudeVersion string
CodexVersion string
+ CursorVersion string
GhVersion string
Config string
SessionCount int
@@ -64,6 +65,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()
@@ -163,6 +165,9 @@ func (r *Report) formatMarkdown(description string) 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)
}
From e8653e31c263cade66632da831acc06bbd8df85c Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:05:49 +0300
Subject: [PATCH 09/26] docs: document Cursor CLI agent support
---
CLAUDE.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 65a70ed8..20044797 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,7 +88,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- Sessions grouped by git repo root in sidebar with tree lines (├─/└─)
- Status: Running, Waiting, Finished, Idle, Error, Starting, Suspended
- Status icons: ● (running/finished), ◐ (waiting), ○ (idle/starting), ✕ (error), · dim (suspended — same dot as idle; dim style + "suspended" label distinguish it)
-- Agent glyph: each session row shows a dim, monochrome per-agent sigil between the status dot and the title — `✻` Claude, `◇` Codex, `△` OpenCode (`agentGlyph` + `AgentGlyphStyle` in `sidebar.go`/`styles.go`); all are width-1 glyphs from well-covered Unicode blocks (Dingbats / Geometric Shapes — same block as the status dots) so they stay aligned in base mono fonts; identity is carried by shape so the status dot keeps sole ownership of color; empty/legacy `Agent` falls back to Claude
+- Agent glyph: each session row shows a dim, monochrome per-agent sigil between the status dot and the title — `✻` Claude, `◇` Codex, `△` OpenCode, `✦` Cursor (`agentGlyph` + `AgentGlyphStyle` in `sidebar.go`/`styles.go`); all are width-1 glyphs from well-covered Unicode blocks (Dingbats / Geometric Shapes — same block as the status dots) so they stay aligned in base mono fonts; identity is carried by shape so the status dot keeps sole ownership of color; empty/legacy `Agent` falls back to Claude
- Keybindings: j/k nav, `Shift+↑/↓` jump to prev/next group header (origin or checkout — from a session row the nearest header above is its own checkout header, so `Shift+↑` surfaces the current group before climbing out; clamps to the first/last row when no header remains, so it doubles as top/bottom), Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex/OpenCode), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch; works on a session, checkout header, or origin header — an origin header bases the new worktree on the group's main clone), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin; origin header = forget the whole group, checkbox-gated), u undo delete (5s window), r restart (confirm, configurable), R rename, m mark session as unread (idle→finished), e editor, p open PR in browser, Y quick approve (waiting sessions), `.` context menu for the row under the cursor, / filter, Ctrl+K command palette, `` ` `` toggle terminal drawer, S settings, W what's new (release notes reel), X dismiss on-screen tip, ! bug report/diagnostics, ? help, ctrl+c quit
- Terminal drawer (`` ` `` key): a collapsible panel holding plain non-agent "shells" (dev servers, log tails, scratch commands), scoped to the selected repo/worktree. Separate from sessions (`internal/shell`, `shells` SQLite table) — never in the sidebar, no hooks/auto-naming. **Placement is layout-aware** (see `renderBody` in `app.go`): in **dual** it splits the right column — preview on top, terminal below (`lipgloss.JoinVertical`), session list untouched and full-height; in **single/stacked** it falls back to a full-width band at the bottom that shrinks `contentHeight`. `renderDrawer(width, maxOuterH)` clamps its outer height to `maxOuterH` (in dual that's `contentHeight - drawerMinPreviewRows`, so the preview keeps ≥5 rows). Renders as a bordered panel (fleet's panel vocabulary, accent border when focused) with tabs inset in the top border and a loud `● TYPING → ` label top-right. **The body is a live virtual-terminal emulator** (`internal/vterm`, wrapping `charmbracelet/x/vt`) fed by a tmux **control-mode `%output` reader** (`internal/tmux/control_output.go` → `OutputReader`, attached per active shell, re-pointed on tab switch/restart): byte- and cursor-accurate, **event-driven** (no capture-pane polling), rendered each frame on the View thread. `syncShellStream`/`startShellStreamAsync`/`teardownShellStream` (drawer.go) own the reader+emulator lifecycle — the attach (a `tmux -C` + PTY fork) and teardown (`Close` = Kill+Wait) run **off the Update goroutine** (async dispatch → `shellStreamReadyMsg`, installed only if the requested target+size are still current; `attachShell` uses a synchronous teardown before its full-screen takeover). On attach the fresh emulator is **seeded via `capture-pane`** (`tmux.CapturePaneANSI` → `drawerSeedBytes`): control mode replays nothing on attach, so without the seed the body is blank until the next output. The reader sizes the pane to the drawer body so wrap points match (`renderDrawer` records `drawerInnerW/H`), and the drawer is a **stable-height viewport** (capped by `drawer_height`, clamped to `[DrawerHeightMin,DrawerHeightMax]`=`[4,14]`), not content-fit. The reader writes bytes into the mutex-guarded emulator and schedules a single coalesced `shellOutputMsg` render wake (`shellWake` CAS); a slow `drawerSyncInterval` tick is the lifecycle/resize backstop. `vterm` strips screen/tmux `ESC k … ST` set-title escapes that x/vt would otherwise leak as visible text. **Always-typing, 2 states** (`drawerMode`: hidden/typing — see `internal/ui/drawer.go`): `` ` `` opens straight into TYPING (auto-creates a shell if the repo has none) — keystrokes forward to the shell pane via the focus-mode control client (`forwardKeyToPane`, which maps any `Ctrl+` → tmux `C-` so the shell's own line-editing keeps working; `Esc` passes through to the shell). **No menu mode**; chrome is a small set of Ctrl chords intercepted before forwarding: `Ctrl+T` new shell, `Ctrl+W` close (twice to confirm a running one, armed via `drawerCloseArmed`), `PgUp`/`PgDn` switch tab (plain or Ctrl-modified both accepted; chosen for reliable delivery — no modifier a terminal might swallow; costs the shell's PageUp/PageDown inside the drawer), `Ctrl+G` full attach (Ctrl+Q returns), `` ` `` close drawer. An **exited** shell restarts on `Enter` (no live process to type to). Cost: the shell loses `Ctrl+T` (transpose) and `Ctrl+W` (delete-word) to the drawer. Status (○ idle / ● running / ✕ exited+code) derives from tmux `pane_current_command` + pane-dead, no hooks. Shell tmux sessions use the `fleetsh_` prefix; removing a worktree kills its shells first (`killShellsForRepo`) so the dir frees for `git worktree remove`. Max body height via `drawer_height` config (default 12).
- Session hotkeys (RTS-style): `Alt+0-9` (or `=` then digit) binds the selected session to a slot; re-pressing `Alt+` on a session already in slot N unbinds; `==` then digit clears any slot; plain `0-9` jumps to the bound session (double-tap within 400ms also attaches); `[N]` badge in sidebar marks bound sessions; bindings persist in SQLite `slot_bindings` table (FK cascade on session delete)
@@ -130,9 +130,10 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- `.fleet.json` / `.fleet.local.json` in repo root (legacy `.bc.json` / `.bc.local.json` still read): `{"workspace": {"list": "cmd", "create": "cmd {{name}} {{branch}}", "destroy": "cmd {{name}}"}}`
- `.fleet.json` / `.fleet.local.json` may also set `{"pr_checks": {"ignore": ["glob", ...]}}` to drop matching CI checks from the PR-badge rollup (path.Match globs; lists from both files merge additively; opt-in, empty by default)
- `.fleet.json` / `.fleet.local.json` may also set `{"copy_files": {"paths": ["path", "dir", "glob/*", ...]}}` to copy gitignored files/dirs/globs from the source repo into each new worktree (filepath.Glob semantics, repo-relative only; lists from both files merge additively; opt-in, empty by default; applies to both git-worktree and shell providers; independent of `copy_claude_settings`)
-- 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`).
+- Multi-agent: per-session agent (Claude, Codex, OpenCode, or Cursor), 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` / `cursor-agent --resume `). Cursor has no fork primitive, so fork-to-worktree stays Claude-only for it too.
- 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, `permission.asked`→waiting (only fires if the user set `permission: ask`; OpenCode defaults to allow-all). 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`: `sessionStart`/`stop`→finished (at rest until a prompt lands), `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.
@@ -158,4 +159,4 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- Chrome extension ID: `haphpcoecelhofejcklinnlbfijgdnih` (stable via `key` in manifest.json)
- 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, Mac only
+- Claude Code + OpenAI Codex + OpenCode + Cursor CLI, Mac only
From 9c8eebfa2fccdb7c907a8dad392ee7098f566be3 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:53:56 +0300
Subject: [PATCH 10/26] docs: add changelog fragment for Cursor CLI support
---
changelog/unreleased/cursor-agent.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 changelog/unreleased/cursor-agent.md
diff --git a/changelog/unreleased/cursor-agent.md b/changelog/unreleased/cursor-agent.md
new file mode 100644
index 00000000..635d9066
--- /dev/null
+++ b/changelog/unreleased/cursor-agent.md
@@ -0,0 +1,5 @@
+---
+type: added
+---
+
+**Cursor CLI support** — Cursor's `cursor-agent` joins Claude, Codex, and OpenCode as a session agent, selectable from the `A` picker or as your `default_agent`.
From 5e76a80bcf89498e31bd8fd47ef89d1f8397c81b Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Mon, 20 Jul 2026 12:56:17 +0300
Subject: [PATCH 11/26] fix(agent): wire the missing Cursor branch into
BuildLaunchCmd
---
internal/agent/agent.go | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 98eea824..2e5c63b1 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -124,6 +124,13 @@ func (t Type) BuildLaunchCmd(o LaunchOpts) string {
}
}
+ if t == Cursor {
+ if o.ResumeID != "" {
+ return fmt.Sprintf("cursor-agent --resume %s", o.ResumeID)
+ }
+ return "cursor-agent"
+ }
+
// Claude (default).
cmd := "claude"
if o.ForkID != "" {
From 6f8d0feff29fa53df7912f2b96ff7a6268b75457 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 15:01:54 +0300
Subject: [PATCH 12/26] fix(hooks): fail closed on unparseable Cursor hook
event entries
---
internal/hooks/cursor_hooks.go | 26 ++++++++++++++++++--------
internal/hooks/cursor_hooks_test.go | 19 +++++++++++++++++++
2 files changed, 37 insertions(+), 8 deletions(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index a45af9bd..5cf13a1c 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -80,7 +80,12 @@ func InjectCursorHooks(configDir string) (bool, error) {
}
for _, event := range cursorHookEvents {
- events[event] = mergeCursorHookEvent(events[event])
+ 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)
@@ -125,11 +130,16 @@ func InjectCursorHooks(configDir string) (bool, error) {
// mergeCursorHookEvent adds fleet's hook to an event's flat entry array,
// preserving any existing (non-fleet) entries and updating the command path
// in place if it changed (e.g. after a rebuild).
-func mergeCursorHookEvent(existing json.RawMessage) json.RawMessage {
+//
+// Fail closed: an existing, non-empty entry that isn't a parseable
+// []cursorHookEntry 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 []cursorHookEntry
- if existing != nil {
+ if len(existing) > 0 {
if err := json.Unmarshal(existing, &entries); err != nil {
- entries = nil
+ return nil, err
}
}
@@ -140,12 +150,12 @@ func mergeCursorHookEvent(existing json.RawMessage) json.RawMessage {
if e.Command != currentCmd {
entries[i].Command = currentCmd
}
- result, _ := json.Marshal(entries)
- return result
+ result, err := json.Marshal(entries)
+ return result, err
}
}
entries = append(entries, cursorHookEntry{Command: currentCmd, Type: "command"})
- result, _ := json.Marshal(entries)
- return result
+ result, err := json.Marshal(entries)
+ return result, err
}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index 39539b57..cb306723 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -71,3 +71,22 @@ func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
t.Errorf("fleet event not added:\n%s", data)
}
}
+
+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, _ := os.ReadFile(path)
+ if string(data) != seed {
+ t.Errorf("hooks.json was modified despite the refusal:\nwant %s\ngot %s", seed, data)
+ }
+}
From 7e3e2a6c1dc71c31db7e6843f412e96e5a653c2a Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 15:14:38 +0300
Subject: [PATCH 13/26] fix(hooks): use a unique temp file for Cursor
hooks.json writes
---
internal/hooks/cursor_hooks.go | 22 +++++++++++++++++++---
internal/hooks/cursor_hooks_test.go | 15 +++++++++++----
2 files changed, 30 insertions(+), 7 deletions(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index 5cf13a1c..7f7748dc 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -113,12 +113,28 @@ func InjectCursorHooks(configDir string) (bool, error) {
if err := os.MkdirAll(configDir, 0755); err != nil {
return false, fmt.Errorf("create config dir: %w", err)
}
- tmpPath := hooksPath + ".tmp"
- if err := os.WriteFile(tmpPath, finalData, 0644); err != nil {
+ // 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 {
- os.Remove(tmpPath)
debuglog.Logger.Error("cursor hooks: failed to rename hooks.json.tmp", "err", err)
return false, fmt.Errorf("rename hooks.json: %w", err)
}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index cb306723..8b31d5ea 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -44,10 +44,17 @@ func TestInjectCursorHooks(t *testing.T) {
t.Errorf("event %q: unexpected hook entry %+v", event, e)
}
}
- // 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.
+ // 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) {
From 16b02ba5be8f5cd11899be93aea9595e2db0f7a4 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 15:41:57 +0300
Subject: [PATCH 14/26] fix(hooks): stop Cursor's sessionStart from forcing
finished on launch
---
CLAUDE.md | 2 +-
cmd/fleet/hook_handler.go | 28 ++++++++++++++++++++--------
cmd/fleet/hook_handler_test.go | 30 ++++++++++++++++++++++++++++++
3 files changed, 51 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 929f2d3d..91a9e6e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,7 +134,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- Multi-agent: per-session agent (Claude, Codex, OpenCode, or Cursor), 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` / `cursor-agent --resume `). Cursor has no fork primitive, so fork-to-worktree stays Claude-only for it too.
- 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, `permission.asked`→waiting (only fires if the user set `permission: ask`; OpenCode defaults to allow-all). 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`: `sessionStart`/`stop`→finished (at rest until a prompt lands), `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.
+- 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/cmd/fleet/hook_handler.go b/cmd/fleet/hook_handler.go
index 3e7db3a2..2591bd9e 100644
--- a/cmd/fleet/hook_handler.go
+++ b/cmd/fleet/hook_handler.go
@@ -70,9 +70,14 @@ func mapEventToStatus(event string) string {
// own UI blocks on a y/n prompt before the command actually runs and
// afterShellExecution fires — so "waiting" is only wrong for auto-approved
// commands, which resolve to "running" again almost immediately.
- case "sessionStart":
- // At rest until a prompt is submitted, same as Claude's SessionStart.
- return "finished"
+ //
+ // 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":
@@ -88,6 +93,14 @@ func mapEventToStatus(event string) string {
}
}
+// 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
@@ -168,12 +181,11 @@ func handleHookHandler() {
"claudeSession", payload.SessionID,
)
- // Extract user prompt and prompt count. beforeSubmitPrompt is Cursor's
- // UserPromptSubmit equivalent (see internal/hooks/cursor_hooks.go).
- isPromptSubmit := payload.HookEventName == "UserPromptSubmit" || payload.HookEventName == "beforeSubmitPrompt"
+ // Extract user prompt and prompt count.
+ promptSubmit := isPromptSubmit(payload.HookEventName)
var userPrompt string
var promptCount int
- if isPromptSubmit && payload.Prompt != "" {
+ if promptSubmit && payload.Prompt != "" {
userPrompt = payload.Prompt
}
@@ -188,7 +200,7 @@ func handleHookHandler() {
}
// Increment prompt count on new user prompt submissions.
- if isPromptSubmit {
+ if promptSubmit {
promptCount++
}
diff --git a/cmd/fleet/hook_handler_test.go b/cmd/fleet/hook_handler_test.go
index 6b55de04..4d187ec6 100644
--- a/cmd/fleet/hook_handler_test.go
+++ b/cmd/fleet/hook_handler_test.go
@@ -19,6 +19,17 @@ func TestMapEventToStatus(t *testing.T) {
{"session.error", "error"},
{"permission.asked", "waiting"},
{"permission.replied", "running"},
+ // Cursor CLI events (see internal/hooks/cursor_hooks.go).
+ {"beforeSubmitPrompt", "running"},
+ {"beforeShellExecution", "waiting"},
+ {"afterShellExecution", "running"},
+ {"stop", "finished"},
+ {"sessionEnd", "dead"},
+ // 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", ""},
}
for _, c := range cases {
@@ -28,6 +39,25 @@ func TestMapEventToStatus(t *testing.T) {
}
}
+func TestIsPromptSubmit(t *testing.T) {
+ cases := []struct {
+ event string
+ want bool
+ }{
+ {"UserPromptSubmit", true},
+ {"beforeSubmitPrompt", true},
+ {"Stop", false},
+ {"stop", false},
+ {"sessionStart", false},
+ {"", false},
+ }
+ for _, c := range cases {
+ if got := isPromptSubmit(c.event); got != c.want {
+ t.Errorf("isPromptSubmit(%q) = %v, want %v", c.event, got, c.want)
+ }
+ }
+}
+
func TestIsCompactSessionStart(t *testing.T) {
cases := []struct {
event, source string
From 98e10de51f0dafbeac218a36b4aff85e474395c9 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 15:42:00 +0300
Subject: [PATCH 15/26] fix(hooks): preserve unknown fields when merging Cursor
hook entries
---
internal/hooks/cursor_hooks.go | 75 ++++++++++++++++++++++-------
internal/hooks/cursor_hooks_test.go | 53 +++++++++++++++++++-
2 files changed, 109 insertions(+), 19 deletions(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index 7f7748dc..a6402397 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -14,8 +14,13 @@ import (
// 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{
- "sessionStart",
"beforeSubmitPrompt",
"beforeShellExecution",
"afterShellExecution",
@@ -143,16 +148,27 @@ func InjectCursorHooks(configDir string) (bool, error) {
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 and updating the command path
-// in place if it changed (e.g. after a rebuild).
+// 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
-// []cursorHookEntry 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.
+// 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 []cursorHookEntry
+ var entries []json.RawMessage
if len(existing) > 0 {
if err := json.Unmarshal(existing, &entries); err != nil {
return nil, err
@@ -161,17 +177,40 @@ func mergeCursorHookEvent(existing json.RawMessage) (json.RawMessage, error) {
currentCmd := GetHookCommand()
- for i, e := range entries {
- if isFleetHook(e.Command) {
- if e.Command != currentCmd {
- entries[i].Command = currentCmd
- }
- result, err := json.Marshal(entries)
- return result, err
+ 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
+ }
+ 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)
}
- entries = append(entries, cursorHookEntry{Command: currentCmd, Type: "command"})
- result, err := json.Marshal(entries)
- return result, err
+ 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
index 8b31d5ea..4e9c5975 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -74,11 +74,62 @@ func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
if !strings.Contains(string(data), "afterFileEdit") {
t.Errorf("user event removed:\n%s", data)
}
- if !strings.Contains(string(data), "sessionStart") {
+ 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 byte-for-byte;
+ // 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
From c5edf5040e70d03a5bb8b0679e71a2d8e8dcbf0f Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 16:14:44 +0300
Subject: [PATCH 16/26] fix(hooks): don't overwrite an existing hooks.json
version field
---
internal/hooks/cursor_hooks.go | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index a6402397..b1dc659f 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -99,11 +99,16 @@ func InjectCursorHooks(configDir string) (bool, error) {
}
root["hooks"] = eventsRaw
- versionRaw, err := json.Marshal(1)
- if err != nil {
- return false, fmt.Errorf("marshal version: %w", err)
+ // 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
}
- root["version"] = versionRaw
finalData, err := json.MarshalIndent(root, "", " ")
if err != nil {
From 81229d6a0ca38b7e3af02ae9ce8aa929287af11a Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 16:14:46 +0300
Subject: [PATCH 17/26] docs(cursor): mention plain cursor-agent launch form,
fix changelog voice
---
CLAUDE.md | 2 +-
changelog/unreleased/cursor-agent.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 91a9e6e5..6ba4ceb0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -131,7 +131,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes
- `.fleet.json` / `.fleet.local.json` in repo root (legacy `.bc.json` / `.bc.local.json` still read): `{"workspace": {"list": "cmd", "create": "cmd {{name}} {{branch}}", "destroy": "cmd {{name}}"}}`
- `.fleet.json` / `.fleet.local.json` may also set `{"pr_checks": {"ignore": ["glob", ...]}}` to drop matching CI checks from the PR-badge rollup (path.Match globs; lists from both files merge additively; opt-in, empty by default)
- `.fleet.json` / `.fleet.local.json` may also set `{"copy_files": {"paths": ["path", "dir", "glob/*", ...]}}` to copy gitignored files/dirs/globs from the source repo into each new worktree (filepath.Glob semantics, repo-relative only; lists from both files merge additively; opt-in, empty by default; applies to both git-worktree and shell providers; independent of `copy_claude_settings`)
-- Multi-agent: per-session agent (Claude, Codex, OpenCode, or Cursor), 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` / `cursor-agent --resume `). Cursor has no fork primitive, so fork-to-worktree stays Claude-only for it too.
+- Multi-agent: per-session agent (Claude, Codex, OpenCode, or Cursor), 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` / `cursor-agent` / `cursor-agent --resume `). Cursor has no fork primitive, so fork-to-worktree stays Claude-only for it too.
- 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, `permission.asked`→waiting (only fires if the user set `permission: ask`; OpenCode defaults to allow-all). 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.
diff --git a/changelog/unreleased/cursor-agent.md b/changelog/unreleased/cursor-agent.md
index 635d9066..7733934c 100644
--- a/changelog/unreleased/cursor-agent.md
+++ b/changelog/unreleased/cursor-agent.md
@@ -2,4 +2,4 @@
type: added
---
-**Cursor CLI support** — Cursor's `cursor-agent` joins Claude, Codex, and OpenCode as a session agent, selectable from the `A` picker or as your `default_agent`.
+**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`.
From da96ae723e5417f0b6ba1f4cb85c111db7d46e4d Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 16:39:10 +0300
Subject: [PATCH 18/26] fix(hooks): correct stale byte-for-byte claim in cursor
hooks test comment
---
internal/hooks/cursor_hooks_test.go | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index 4e9c5975..a8d001a9 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -83,8 +83,10 @@ 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 byte-for-byte;
- // only fleet's own entry gets appended alongside them.
+ // 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"}` +
From 1a0a612a6a9306c5ec1ec628e967c3aae327d712 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 16:57:22 +0300
Subject: [PATCH 19/26] fix(hooks): treat a null cursor hooks section as empty
to avoid nil-map panic
---
internal/hooks/cursor_hooks.go | 6 +++++-
internal/hooks/cursor_hooks_test.go | 26 ++++++++++++++++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index b1dc659f..a7cfd14e 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -80,7 +80,11 @@ func InjectCursorHooks(configDir string) (bool, error) {
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)
}
- } else {
+ }
+ 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)
}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index a8d001a9..bc6cb5b0 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -150,3 +150,29 @@ func TestInjectCursorHooksRefusesMalformedEventEntries(t *testing.T) {
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)
+ }
+}
From bbe60eaaa8616a3a16f93cc243f1eb18e0ae2ed9 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Tue, 21 Jul 2026 19:44:07 +0300
Subject: [PATCH 20/26] fix(hooks): check os.ReadFile errors in cursor hooks
tests
---
internal/hooks/cursor_hooks_test.go | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index bc6cb5b0..aac47678 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -67,7 +67,10 @@ func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
if _, err := InjectCursorHooks(dir); err != nil {
t.Fatalf("InjectCursorHooks: %v", err)
}
- data, _ := os.ReadFile(filepath.Join(dir, "hooks.json"))
+ 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)
}
@@ -145,7 +148,10 @@ func TestInjectCursorHooksRefusesMalformedEventEntries(t *testing.T) {
if _, err := InjectCursorHooks(dir); err == nil {
t.Fatal("expected InjectCursorHooks to error on malformed event entries, got nil")
}
- data, _ := os.ReadFile(path)
+ 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)
}
From 2b3d694eeb919a1fa646b38ac72c2f2f26e7c248 Mon Sep 17 00:00:00 2001
From: Eyal Vainer
Date: Wed, 22 Jul 2026 10:12:03 +0300
Subject: [PATCH 21/26] fix(hooks): correct misleading seed-comment in cursor
hooks test
Co-Authored-By: Claude Sonnet 5
---
internal/hooks/cursor_hooks_test.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index aac47678..487a386a 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -59,7 +59,9 @@ func TestInjectCursorHooks(t *testing.T) {
func TestInjectCursorHooksPreservesUserHooks(t *testing.T) {
dir := t.TempDir()
- // Pre-existing user hook on an event we don't manage + on one we do.
+ // 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)
From 56ffad8130e4f237edc2ed7d580f8e8effbd6477 Mon Sep 17 00:00:00 2001
From: Yuval Hayke
Date: Sun, 2 Aug 2026 10:58:37 +0300
Subject: [PATCH 22/26] fix(hooks): don't panic on a null ~/.cursor/hooks.json
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
---
internal/hooks/cursor_hooks.go | 13 ++++++-
internal/hooks/cursor_hooks_test.go | 53 +++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/internal/hooks/cursor_hooks.go b/internal/hooks/cursor_hooks.go
index a7cfd14e..fe06185c 100644
--- a/internal/hooks/cursor_hooks.go
+++ b/internal/hooks/cursor_hooks.go
@@ -70,6 +70,13 @@ func InjectCursorHooks(configDir string) (bool, error) {
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 {
@@ -189,8 +196,12 @@ func mergeCursorHookEvent(existing json.RawMessage) (json.RawMessage, error) {
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
+ 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
}
diff --git a/internal/hooks/cursor_hooks_test.go b/internal/hooks/cursor_hooks_test.go
index 487a386a..43cdeb9d 100644
--- a/internal/hooks/cursor_hooks_test.go
+++ b/internal/hooks/cursor_hooks_test.go
@@ -184,3 +184,56 @@ func TestInjectCursorHooksHandlesNullHooksSection(t *testing.T) {
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)
+ }
+}
From e5e24236d8432564ed41340645f157368d3e841e Mon Sep 17 00:00:00 2001
From: Yuval Hayke
Date: Sun, 2 Aug 2026 10:58:50 +0300
Subject: [PATCH 23/26] fix(cursor): stop pinning sessions to waiting, and read
stop status
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
---
cmd/fleet/hook_handler.go | 40 ++++++++++++++++-------
cmd/fleet/hook_handler_test.go | 58 ++++++++++++++++++++--------------
internal/hooks/hook_watcher.go | 17 ++++++++--
3 files changed, 77 insertions(+), 38 deletions(-)
diff --git a/cmd/fleet/hook_handler.go b/cmd/fleet/hook_handler.go
index 2591bd9e..da42526e 100644
--- a/cmd/fleet/hook_handler.go
+++ b/cmd/fleet/hook_handler.go
@@ -21,6 +21,8 @@ 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
@@ -28,7 +30,7 @@ type hookPayload struct {
// 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 string) string {
+func mapEventToStatus(event, status string) string {
switch event {
case "UserPromptSubmit":
return "running"
@@ -64,12 +66,14 @@ func mapEventToStatus(event string) string {
// 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).
- // Cursor has no dedicated permission/approval hook, so beforeShellExecution/
- // afterShellExecution bracket the interactive approval prompt instead: the
- // hook fires and returns immediately, then (unless auto-approved) Cursor's
- // own UI blocks on a y/n prompt before the command actually runs and
- // afterShellExecution fires — so "waiting" is only wrong for auto-approved
- // commands, which resolve to "running" again almost immediately.
+ // 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
@@ -80,11 +84,16 @@ func mapEventToStatus(event string) string {
// cursorHookEvents in internal/hooks/cursor_hooks.go).
case "beforeSubmitPrompt":
return "running"
- case "beforeShellExecution":
- return "waiting"
- case "afterShellExecution":
+ 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":
return "dead"
@@ -138,7 +147,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,
@@ -154,7 +170,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 {
diff --git a/cmd/fleet/hook_handler_test.go b/cmd/fleet/hook_handler_test.go
index 4d187ec6..8fae0750 100644
--- a/cmd/fleet/hook_handler_test.go
+++ b/cmd/fleet/hook_handler_test.go
@@ -4,37 +4,49 @@ import "testing"
func TestMapEventToStatus(t *testing.T) {
cases := []struct {
- event string
- want string
+ 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"},
+ {"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"},
- {"beforeShellExecution", "waiting"},
- {"afterShellExecution", "running"},
- {"stop", "finished"},
- {"sessionEnd", "dead"},
+ {"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", "", "dead"},
// 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", ""},
+ {"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); got != c.want {
- t.Errorf("mapEventToStatus(%q) = %q, want %q", c.event, got, c.want)
+ 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)
}
}
}
diff --git a/internal/hooks/hook_watcher.go b/internal/hooks/hook_watcher.go
index d58a5a6a..3f7556d7 100644
--- a/internal/hooks/hook_watcher.go
+++ b/internal/hooks/hook_watcher.go
@@ -255,13 +255,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{}{
@@ -271,3 +276,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"
+}
From 4e5aeb3cf1395fb54d3adae8063eb7e861a34bbb Mon Sep 17 00:00:00 2001
From: Yuval Hayke
Date: Sun, 2 Aug 2026 10:59:04 +0300
Subject: [PATCH 24/26] fix(cursor): gate fork on agent support, surface
hook-install failure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 " (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
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
---
internal/agent/agent.go | 10 ++++++++++
internal/agent/agent_test.go | 21 +++++++++++++++++++++
internal/ui/app.go | 29 ++++++++++++++++++++++++-----
3 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 2e5c63b1..56ee59f1 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -67,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).
@@ -125,6 +132,9 @@ 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)
}
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 7689df7c..6e68a9ea 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -39,6 +39,10 @@ func TestBuildLaunchCmd(t *testing.T) {
{"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) {
@@ -49,6 +53,23 @@ 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" || Cursor.Binary() != "cursor-agent" {
t.Errorf("unexpected Binary(): claude=%q codex=%q opencode=%q cursor=%q", Claude.Binary(), Codex.Binary(), OpenCode.Binary(), Cursor.Binary())
diff --git a/internal/ui/app.go b/internal/ui/app.go
index a019dd94..4f655390 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -3827,6 +3827,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.ClaudeSessionID == "" {
h.setError(fmt.Errorf("cannot fork: session has no Claude conversation ID yet"))
return nil
@@ -6667,9 +6671,11 @@ func (h *Home) loadSessions() tea.Msg {
}
// 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
@@ -6686,6 +6692,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.
@@ -6905,6 +6918,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:
@@ -6940,11 +6963,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(),
From fe2d5d4977b7d09b36809882ed5531ab142e466c Mon Sep 17 00:00:00 2001
From: Yuval Hayke
Date: Sun, 2 Aug 2026 10:59:04 +0300
Subject: [PATCH 25/26] docs: add Cursor to the README agent lists
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
---
README.md | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
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 |
From e9216b49d401d77218b070dc7562abc483460bc8 Mon Sep 17 00:00:00 2001
From: Yuval Hayke
Date: Sun, 2 Aug 2026 16:32:08 +0300
Subject: [PATCH 26/26] fix(cursor): sessionEnd is a conversation end, not a
process death
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01RNvng2fxraDgEXNtmtgHs9
---
cmd/fleet/hook_handler.go | 9 ++++++++-
cmd/fleet/hook_handler_test.go | 4 ++--
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/cmd/fleet/hook_handler.go b/cmd/fleet/hook_handler.go
index da42526e..606eab67 100644
--- a/cmd/fleet/hook_handler.go
+++ b/cmd/fleet/hook_handler.go
@@ -96,7 +96,14 @@ func mapEventToStatus(event, status string) string {
}
return "finished"
case "sessionEnd":
- return "dead"
+ // 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 ""
}
diff --git a/cmd/fleet/hook_handler_test.go b/cmd/fleet/hook_handler_test.go
index 8fae0750..59598ede 100644
--- a/cmd/fleet/hook_handler_test.go
+++ b/cmd/fleet/hook_handler_test.go
@@ -32,8 +32,8 @@ func TestMapEventToStatus(t *testing.T) {
{"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", "", "dead"},
+ {"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