Skip to content
Open
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,57 @@ This is the lane scheduled playbooks use to fire instructions at
existing tasks without manual intervention. `flow run playbook <slug>`
accepts the same flags for ad-hoc per-run instructions.

### Desktop notifications

When you're running several task tabs at once, a session that stops to
ask a question can sit unnoticed for a long time. flow installs a
Claude Code `Notification` hook that raises a macOS banner the moment a
session blocks on you — titled with the flow task, so you know which tab
is asking without hunting.

Click the banner and flow brings that tab to the front.

```bash
# Focus the tab running a session — what the banner click invokes.
flow focus <session-id>
flow focus <task-slug> # same thing, if the task has been opened
```

Banners fire on the two notification types that mean "blocked waiting on
a human" (`permission_prompt` and `idle_prompt`), and are grouped per
session so a chatty task replaces its own banner instead of burying the
others. Autonomous `flow do --auto` runs also notify when they finish or
die, since they have no tab to watch.

**Three setup notes:**

- **Keep the installed binary current.** The banner's click action invokes
the flow binary that posted it, by absolute path. If you build from
source, run `make install` so `~/.local/bin/flow` isn't a stale copy
without the `focus` subcommand.

- **Clickable banners need `terminal-notifier`.** macOS only lets a
registered app attach an action to a notification, which `osascript`
cannot do. `flow init` installs it via Homebrew automatically. Without
it you still get banners — they just aren't clickable. To install it
yourself: `brew install terminal-notifier`.
- **Make the banners stick around.** By default macOS shows them as
*banners*, which auto-dismiss after a few seconds — easy to miss, which
defeats the point. In System Settings → Notifications → **terminal-notifier**,
set **Alert style** to **Alerts** and they stay on screen until you
dismiss or click them (the same behaviour Calendar uses for events).
macOS reserves this choice for you; no CLI flag can set it.

Blocked-session banners are posted with `-ignoreDnD`, so a Focus mode
won't hide them — a stalled session is exactly what shouldn't be
silenced. Auto-run completion banners deliberately don't do this.

Terminal support for click-to-focus matches what each terminal exposes:
iTerm2, Terminal.app, kitty and zellij focus the exact tab. Ghostty does
too, once its 1.4.0 release lands (the `tty` property the lookup needs
isn't in 1.3.x). Warp exposes no scripting surface for selecting a tab,
so a click foregrounds the app and leaves the tab switch to you.

### `flow stats`

Show usage & ROI analytics derived from your own flow history — how many
Expand Down
Binary file added internal/.DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func Run(args []string) int {
return cmdAdd(rest)
case "do":
return cmdDo(rest)
case "focus":
return cmdFocus(rest)
case "__auto-exec":
// Hidden: the detached supervisor entry point for `flow do --auto`.
// Not listed in usage; invoked only by autoLauncher.
Expand Down Expand Up @@ -103,6 +105,7 @@ Sessions:
flow do <ref> [--fresh] [--dangerously-skip-permissions]
flow do --auto <ref> (run headlessly in the background; self-completes via flow done)
flow done <ref>
flow focus <session-id|slug> (bring the terminal tab running that session to the front)
flow hook session-start (SessionStart hook handler — wire via ~/.claude/settings.json)

Read:
Expand Down
22 changes: 22 additions & 0 deletions internal/app/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,37 @@ func recordAutoRunLaunched(db *sql.DB, slug string, pid int, logPath string) err
// finalizeAutoRun records a terminal auto-run status ('completed' or
// 'dead') and clears the supervisor pid. Best-effort: errors are returned
// for the caller to log, but the run is over regardless.
//
// This is also where the auto-run notification fires. An autonomous run
// has no tab and no human watching it, so without a banner its outcome
// is invisible until the user thinks to check. Notifying here rather
// than at the call sites means every terminal transition is covered —
// including the early-return path where the harness fails to resolve.
func finalizeAutoRun(db *sql.DB, slug, status string) error {
now := flowdb.NowISO()
_, err := db.Exec(
`UPDATE tasks SET auto_run_status=?, auto_run_finished=?, auto_run_pid=NULL, updated_at=? WHERE slug=?`,
status, now, now, slug,
)
notifyAutoRun(slug, status, autoRunLogPath(db, slug))
return err
}

// autoRunLogPath returns the recorded log path for a task's auto run, or
// "" if unavailable. Used to point a 'dead' notification at the log
// worth reading. Errors are swallowed — a missing path just means a
// slightly less helpful banner.
func autoRunLogPath(db *sql.DB, slug string) string {
t, err := flowdb.GetTask(db, slug)
if err != nil || t == nil {
return ""
}
if t.AutoRunLog.Valid {
return t.AutoRunLog.String
}
return ""
}

// reconcileAutoRun promotes a stale 'running' row to 'dead' when its
// supervisor pid is no longer alive (crash, kill -9, reboot — anything
// that prevented finalizeAutoRun from running). No-op for any other
Expand Down
164 changes: 164 additions & 0 deletions internal/app/focus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package app

import (
"flow/internal/flowdb"
"flow/internal/spawner"
"fmt"
"os"
"strings"
)

// cmdFocus implements `flow focus <session-id|slug>`.
//
// It is the CLI surface for spawner.FocusSession, which until now was
// reachable only from inside `flow do` (do.go's live-session guard).
// The notification hook needs to focus a session by ID from outside any
// flow session — a notification banner's click action runs
// `flow focus <session-id>` — so the capability had to become a command.
//
// The argument accepts either a session UUID (what the Notification
// hook payload carries) or a task slug (what a human at a prompt would
// naturally type). Slugs are tried only when the argument doesn't look
// like a UUID, so a task that is somehow slugged like a UUID can't
// shadow a real session.
//
// Exit codes follow the repo convention: 0 = focused, 1 = runtime error
// or no matching tab, 2 = usage error. The "no matching tab" miss is a
// 1 rather than a 0 so a caller can branch on whether the focus
// actually happened.
func cmdFocus(args []string) int {
fs := flagSet("focus")
quiet := fs.Bool("quiet", false, "suppress output; report result via exit code only")
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: flow focus <session-id|task-slug> [--quiet]")
return 2
}
ref := strings.TrimSpace(rest[0])
if ref == "" {
fmt.Fprintln(os.Stderr, "error: focus requires a session id or task slug")
return 2
}

sessionID, task := resolveFocusTarget(ref)
if sessionID == "" {
fmt.Fprintf(os.Stderr, "error: no session found for %q — pass a session id, or a task slug that has been opened with `flow do`\n", ref)
return 1
}

// Resolve the harness from the task when we have one: the backends
// filter the process table by the harness binary name, and a task
// opened under codex/gemini won't match a hardcoded "claude".
// harnessForSpawn(nil) falls back to ambient-then-claude, which is
// the right guess when the session id isn't tracked by any task.
h, err := harnessForSpawn(task)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}

focused, err := spawner.FocusSession(sessionID, h.Binary())
if err != nil {
fmt.Fprintf(os.Stderr, "error: focus failed: %v\n", err)
return 1
}
if !focused {
if !*quiet {
// Not an error the user caused — several backends (Warp,
// Ghostty) cannot select a specific tab at all, and a
// session may simply not be open in this terminal.
fmt.Fprintf(os.Stderr, "no matching tab found for session %s in the active terminal (%s)\n",
sessionID, spawner.Detect())
}
return 1
}
if !*quiet {
if task != nil {
fmt.Printf("focused: %s\n", task.Slug)
} else {
fmt.Printf("focused: %s\n", sessionID)
}
}
return 0
}

// resolveFocusTarget maps the user's argument to a session UUID and,
// when known, the task carrying it. Returns ("", nil) when nothing
// resolves.
//
// A UUID-shaped argument is used directly; the task lookup is a
// best-effort enrichment so we can name the task in output and pick the
// right harness, and its failure is not fatal — focusing a session that
// flow doesn't track is still a legitimate request.
func resolveFocusTarget(ref string) (string, *flowdb.Task) {
if looksLikeUUID(ref) {
return ref, taskBySessionIDQuiet(ref)
}

dbPath, err := flowDBPath()
if err != nil {
return "", nil
}
db, err := flowdb.OpenDB(dbPath)
if err != nil {
return "", nil
}
defer db.Close()

// includeArchived: an archived task can still have a live session
// in a tab the user wants to reach.
t, err := ResolveTask(db, ref, true)
if err != nil || t == nil {
return "", nil
}
if !t.SessionID.Valid || t.SessionID.String == "" {
return "", nil
}
return t.SessionID.String, t
}

// taskBySessionIDQuiet reverse-looks-up a task by session id, swallowing
// every error. Callers use it for enrichment only.
func taskBySessionIDQuiet(sessionID string) *flowdb.Task {
dbPath, err := flowDBPath()
if err != nil {
return nil
}
db, err := flowdb.OpenDB(dbPath)
if err != nil {
return nil
}
defer db.Close()
t, err := flowdb.TaskBySessionID(db, sessionID)
if err != nil {
return nil
}
return t
}

// looksLikeUUID reports whether s has the shape 8-4-4-4-12 hex digits.
// Deliberately laxer than the version/variant-pinned regex the focus
// backends use on ps output: this only decides whether to treat the
// argument as a session id or a slug, and a harness could legitimately
// mint a non-v4 id.
func looksLikeUUID(s string) bool {
groups := strings.Split(s, "-")
if len(groups) != 5 {
return false
}
for i, want := range []int{8, 4, 4, 4, 12} {
if len(groups[i]) != want {
return false
}
for _, r := range groups[i] {
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
if !isHex {
return false
}
}
}
return true
}
Loading
Loading