Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions cmd/fleetops/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// `--demo`: launch the same TUI seeded with a fixed synthetic fleet instead
// of scanning ~/.claude/projects — no real data read, nothing written to
// ~/.fleetops (see internal/tui.NewDemo).
// `--rename-tabs`: opt-in (default off) — auto-rename the cmux tab of each
// unambiguously-mapped loop to reflect its state/delegation (see
// internal/tui tab-title sync; cmux-only, a terminal write side-effect).
// Subcommands: `hook notify|session-start|session-end` (Claude Code hook
// entry points, see hook.go), `hooks install|uninstall` (register/remove
// those hooks in ~/.claude/settings.json, see hooks.go), and
Expand Down Expand Up @@ -35,18 +38,49 @@ func main() {
case "report":
runReportCmd(os.Args[2:])
return
case "--demo":
runTUI(true)
return
case "help", "--help", "-h":
fmt.Print(helpText())
return
default:
fmt.Fprintf(os.Stderr, "fleetops: unknown command %q\n", os.Args[1])
os.Exit(1)
// Not a subcommand — parse the remaining args as TUI flags
// (--demo / --rename-tabs). An unknown token still errors (naming the
// OFFENDING token, not just os.Args[1]), so `fleetops bogus` keeps its
// exit-1 behavior.
f, bad, ok := parseTUIFlags(os.Args[1:])
if !ok {
fmt.Fprintf(os.Stderr, "fleetops: unknown command %q\n", bad)
os.Exit(1)
}
runTUI(f.demo, f.renameTabs)
return
}
}
runTUI(false, false)
}

// tuiFlags are the flags that modify the TUI launch (no subcommand).
type tuiFlags struct {
demo bool
renameTabs bool
}

// parseTUIFlags parses the TUI-launch flags out of args (everything after the
// program name). Order-independent; on the first unrecognized token it returns
// ok=false and that token (unknown), so the caller reports the OFFENDING token
// rather than a misattributed os.Args[1]. Pure, so the routing is unit-testable
// without starting a real Bubble Tea program.
func parseTUIFlags(args []string) (f tuiFlags, unknown string, ok bool) {
for _, a := range args {
switch a {
case "--demo":
f.demo = true
case "--rename-tabs":
f.renameTabs = true
default:
return tuiFlags{}, a, false
}
}
runTUI(false)
return f, "", true
}

// helpText is `fleetops --help`/`-h`/`help`'s full output: a one-line
Expand All @@ -63,6 +97,7 @@ Code sessions and lets you approve/resume/inject/kill them from one TUI.
Usage:
fleetops launch the fleet cockpit (TUI)
fleetops --demo launch the TUI with a synthetic fleet — no real data, no disk writes
fleetops --rename-tabs (with the TUI) mirror each loop's state + goal onto its cmux tab title (opt-in, cmux only)
fleetops report [--since D] plain-text summary of the event history (default 24h)
fleetops hooks install register fleetops's Claude Code hooks (gate/idle detection)
fleetops hooks uninstall remove them
Expand Down Expand Up @@ -101,14 +136,16 @@ TUI keymap:
// directly testable without starting a real Bubble Tea program (Run()
// takes over the terminal and blocks on input, unsafe to invoke in a
// test).
func newModel(demo bool) tea.Model {
func newModel(demo, renameTabs bool) tea.Model {
if demo {
// --demo ignores renameTabs: demo never scans real loops, so there is
// nothing to mirror onto a tab (and demo must touch nothing real).
return tui.NewDemo()
}
return tui.New()
return tui.New().WithRenameTabs(renameTabs)
}

func runTUI(demo bool) {
func runTUI(demo, renameTabs bool) {
if demo {
// --demo ignores ~/.fleetops/settings.json entirely and always spawns
// with the built-in ["claude"]. Demo mode's contract is "nothing real",
Expand All @@ -121,7 +158,7 @@ func runTUI(demo bool) {
// keymap.
control.UseDefaultSpawnCommand()
}
p := tea.NewProgram(newModel(demo), tea.WithAltScreen())
p := tea.NewProgram(newModel(demo, renameTabs), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "fleetops:", err)
os.Exit(1)
Expand Down
45 changes: 43 additions & 2 deletions cmd/fleetops/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,47 @@ func TestHelpText_MentionsDemoFlag(t *testing.T) {
}
}

func TestParseTUIFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantDemo bool
wantRenameTabs bool
wantUnknown string
wantOK bool
}{
{"no flags", nil, false, false, "", true},
{"demo only", []string{"--demo"}, true, false, "", true},
{"rename-tabs only", []string{"--rename-tabs"}, false, true, "", true},
{"both, any order", []string{"--rename-tabs", "--demo"}, true, true, "", true},
{"unknown token refuses", []string{"--bogus"}, false, false, "--bogus", false},
{"names the offending token, not the first arg", []string{"--demo", "--nope"}, false, false, "--nope", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, unknown, ok := parseTUIFlags(tc.args)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !ok {
if unknown != tc.wantUnknown {
t.Errorf("unknown token = %q, want %q", unknown, tc.wantUnknown)
}
return // on refusal the flags are the zero value; nothing else to check
}
if f.demo != tc.wantDemo || f.renameTabs != tc.wantRenameTabs {
t.Errorf("parseTUIFlags(%v) = %+v, want demo=%v renameTabs=%v", tc.args, f, tc.wantDemo, tc.wantRenameTabs)
}
})
}
}

func TestHelpText_MentionsRenameTabsFlag(t *testing.T) {
if got := helpText(); !strings.Contains(got, "--rename-tabs") {
t.Errorf("expected help text to mention --rename-tabs, got:\n%s", got)
}
}

// TestNewModel_DemoFlag_RoutesToDemoConstructor is the "--demo flag routes
// to demo mode" proof at this package's level: newModel(true) must be
// tui.NewDemo()'s result, not tui.New()'s — verified through the ONLY
Expand All @@ -108,15 +149,15 @@ func TestHelpText_MentionsDemoFlag(t *testing.T) {
// would take over the terminal and block on input), so this is safe to
// run in-process.
func TestNewModel_DemoFlag_RoutesToDemoConstructor(t *testing.T) {
demoView := newModel(true).View()
demoView := newModel(true, false).View()
if !strings.Contains(demoView, "demo mode") {
t.Errorf("newModel(true).View() did not contain the demo status line:\n%s", demoView)
}
if !strings.Contains(demoView, "dev-box") {
t.Errorf("newModel(true).View() did not contain the demo hostname \"dev-box\":\n%s", demoView)
}

normalView := newModel(false).View()
normalView := newModel(false, false).View()
if strings.Contains(normalView, "demo mode") {
t.Errorf("newModel(false).View() unexpectedly contained the demo status line:\n%s", normalView)
}
Expand Down
90 changes: 84 additions & 6 deletions internal/control/actuation.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,8 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu
if entry, err := sessions.ReadSession(sessionsDir, sessionID); err == nil && entry.TTY != "" && pidTTYFn(entry.PID) == normalizeTTY(entry.TTY) {
// Tier 1a — session-unique tty. Probe every available TTYLocator
// backend; first hit wins (no ambiguity guard needed).
for _, c := range avail {
if t, ok := tierOneA(c, entry.TTY); ok {
return boundController{ctrl: c, target: t}, true, true
}
if c, t, ok := tierOneAAcross(avail, entry.TTY); ok {
return boundController{ctrl: c, target: t}, true, true
}
// Tier 1h — the host terminal writes to the session in place, keyed by
// the registry's host_app + window_id.
Expand Down Expand Up @@ -183,6 +181,36 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu
// Tier 1b — cwd is many-to-one, so probe ALL available backends and count
// matches; >=2 distinct backends matching is cross-backend ambiguity and
// must refuse, never silently pick one.
if c, t, ok := tierOneBAcross(avail, projectDir); ok {
return boundController{ctrl: c, target: t}, true, true
}
return nil, true, false
}

// tierOneAAcross runs Tier 1a (the session-unique tty locate) across the
// available backends: the first TTYLocator hit wins, with NO ambiguity guard
// because a tty is session-unique by construction (see ResolveActuationTarget's
// Tier 1a doc). Extracted so the tab-title resolver reuses the EXACT same
// locate discipline as typed actuation rather than re-spelling it (DRY: if the
// tty rule changes, both move together).
func tierOneAAcross(avail []Controller, tty string) (Controller, Target, bool) {
for _, c := range avail {
if t, ok := tierOneA(c, tty); ok {
return c, t, true
}
}
return nil, Target{}, false
}

// tierOneBAcross runs Tier 1b (the cwd LocateClaude probe) across the available
// backends, REFUSING on cross-backend ambiguity: because cwd is many-to-one it
// counts matches and returns not-found when >=2 DISTINCT backends each locate a
// claude surface for the same projectDir — the cross-backend analogue of
// LocateClaude's own ">1 match" refusal. Exactly one match → use it; zero or
// >=2 → not found. Extracted alongside tierOneAAcross for the same DRY reason
// (see its doc) — this is the single home of the fail-closed ambiguity rule
// both actuation and tab-titling depend on.
func tierOneBAcross(avail []Controller, projectDir string) (Controller, Target, bool) {
var matchedCtrl Controller
var matchedTarget Target
matches := 0
Expand All @@ -193,9 +221,59 @@ func ResolveActuationTarget(sessionsDir, sessionID, projectDir string) (act Actu
}
}
if matches == 1 {
return boundController{ctrl: matchedCtrl, target: matchedTarget}, true, true
return matchedCtrl, matchedTarget, true
}
return nil, true, false
return nil, Target{}, false
}

// ResolveTabTitler resolves the terminal surface a loop's tab-title write
// should land on, reusing ResolveActuationTarget's SAME fail-closed locate
// discipline — Tier 1a (session-unique tty) then Tier 1b (cwd LocateClaude
// across every backend, REFUSING on cross-backend ambiguity) — but returning a
// TabTitler + its Target instead of an Actuator. A tab rename writes terminal
// CHROME, not the session, so it must not travel the send/keystroke Actuator
// seam (pivot §5.1: borrow the discipline, not the types).
//
// Tier 1h (host-send) is deliberately OMITTED: a bare host window has no cmux
// tab to rename, and cmux is the only TabTitler today — so there is nothing for
// a host-send tier to resolve to here.
//
// ok=false — and the caller MUST skip, never rename a guessed tab — when: no
// backend is available, the cwd mapping is ambiguous (>=2 backends match), or
// the resolved backend does not implement TabTitler (tmux/orca ⇒ no-op). Same
// fail-closed posture as ResolveActuationTarget.
func ResolveTabTitler(sessionsDir, sessionID, projectDir string) (TabTitler, Target, bool) {
ctrl, target, ok := resolveClaudeSurface(sessionsDir, sessionID, projectDir)
if !ok {
return nil, Target{}, false
}
titler, ok := ctrl.(TabTitler)
if !ok {
return nil, Target{}, false // resolved backend can't title tabs (tmux/orca) — no-op
}
return titler, target, true
}

// resolveClaudeSurface is the (Controller, Target) resolution core behind
// tab-titling: Tier 1a (tty) then Tier 1b (cwd, ambiguity-refusing) from
// ResolveActuationTarget, WITHOUT Tier 1h (host-send has no Controller/Target)
// and WITHOUT wrapping the result in an Actuator. It shares the exact tier
// helpers ResolveActuationTarget uses (tierOneAAcross/tierOneBAcross), so the
// fail-closed locate rules cannot drift between the two paths; it stays a
// SEPARATE function rather than being folded into ResolveActuationTarget
// because that function's Actuator return and interleaved Tier 1h branch are
// load-bearing for typed actuation and must not change shape.
func resolveClaudeSurface(sessionsDir, sessionID, projectDir string) (Controller, Target, bool) {
avail := availableBackends()
if entry, err := sessions.ReadSession(sessionsDir, sessionID); err == nil && entry.TTY != "" && pidTTYFn(entry.PID) == normalizeTTY(entry.TTY) {
if c, t, ok := tierOneAAcross(avail, entry.TTY); ok {
return c, t, true
}
}
if len(avail) == 0 {
return nil, Target{}, false
}
return tierOneBAcross(avail, projectDir)
}

// availableBackends returns the backends usable right now, in the shared
Expand Down
44 changes: 44 additions & 0 deletions internal/control/cmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,50 @@ func cmuxInterruptCmd(surfaceRef, windowRef string) []string {
return append(argv, "escape")
}

// SetTabTitle implements control.TabTitler: it renames the cmux TAB (window
// chrome) hosting a surface to title, via `cmux rename-tab --surface <ref> --
// <title>` (the name is positional, after the `--` terminator). Routed through
// the SAME bounded-exec discipline every other cmux actuation uses
// (runWithTimeout, via cmuxTabRunner) so a wedged cmux can never hang a scan
// tick — a failure/timeout returns an error and the caller degrades to "didn't
// rename", never a hang.
//
// SAME-WORKSPACE ONLY in v1: unlike Resume/Approve/Focus this passes NO
// `--window` ref (see appendCmuxWindow), so cmux scopes the `--surface` ref to
// the caller's own workspace and a tab in another workspace fails (→ caller
// skips). Cross-workspace tab rename is a documented follow-up (pivot §5.1),
// not fabricated here.
//
// cmux is the ONLY backend implementing TabTitler today; tmux/iTerm2/orca are
// interface-sufficient follow-ups.
//
// NOT live-verified: no `cmux` binary is installed on the machine this was
// written on, and the `rename-tab` subcommand's exact contract must be
// confirmed against a real cmux (`cmux rename-tab --help`) before this is
// relied on — cmux's public docs contradict each other on the surface-rename
// verbs (pivot §5.1). The injectable cmuxTabRunner seam is what lets the argv
// shape and error propagation be unit-tested WITHOUT that binary in the
// meantime.
func (cmuxController) SetTabTitle(t Target, title string) error {
return cmuxTabRunner(cmuxRenameTabCmd(t.ID, title))
}

// cmuxTabRunner is the injectable exec seam SetTabTitle's cmux call goes
// through — runWithTimeout in production (bounded by actuationTimeout), swapped
// by a fake in tests so the exact argv and error propagation are verifiable
// WITHOUT a cmux binary installed. A func var defaulted to the real impl, the
// same injectable-seam idiom cmux.go already uses for ttyResolver.
var cmuxTabRunner = runWithTimeout

// cmuxRenameTabCmd builds the argv that renames the tab hosting a surface:
// cmux rename-tab --surface <ref> -- <title>. The title is positional AFTER the
// `--` terminator so a title beginning with a dash (e.g. a glyph that reads as
// a flag) is never mis-parsed as an option. No `--window`: v1 is same-workspace
// only (see SetTabTitle).
func cmuxRenameTabCmd(surfaceRef, title string) []string {
return []string{"cmux", "rename-tab", "--surface", surfaceRef, "--", title}
}

// cmuxTreeJSON runs `cmux tree --json`, bounded by availabilityTimeout so a
// wedged cmux never hangs a keypress.
func cmuxTreeJSON() ([]byte, error) {
Expand Down
Loading