diff --git a/CHANGELOG.md b/CHANGELOG.md index cf07f27..064d67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Added + +- **`dispatch watch --exec `**: run a command on each session attention transition while streaming. Session context is passed through `DISPATCH_SESSION_ID`, `DISPATCH_SESSION_STATE`, `DISPATCH_SESSION_PREV_STATE`, `DISPATCH_SESSION_REPO`, `DISPATCH_SESSION_BRANCH`, `DISPATCH_SESSION_FOLDER`, and `DISPATCH_SESSION_SUMMARY` environment variables. Hook output goes to stderr and a failing hook never stops the watch loop. + ## [v0.14.0] — 2026-07-18 This release turns Dispatch from a TUI-only tool into a scriptable CLI: most session operations are now available as non-interactive commands with JSON output, and the TUI gains a git status overlay plus several navigation and organization features. diff --git a/README.md b/README.md index bf2de97..56b511f 100644 --- a/README.md +++ b/README.md @@ -398,10 +398,32 @@ dispatch watch --once --json # JSON snapshot dispatch watch # stream transitions (Ctrl-C to stop) dispatch watch --interval 10s # custom poll interval dispatch watch --once --repo jongio/dispatch # filter by repo +dispatch watch --exec 'notify-send "$DISPATCH_SESSION_STATE"' # run a hook on each transition ``` The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, or `--folder` to limit which sessions are monitored. +#### Transition hooks + +`--exec ` runs a command every time a session changes attention state while streaming (it cannot be combined with `--once`). The command runs through your shell, so pipelines and quoting work as usual. Session context is passed as environment variables, never interpolated into the command string: + +| Variable | Value | +| --- | --- | +| `DISPATCH_SESSION_ID` | Full session ID | +| `DISPATCH_SESSION_STATE` | New attention state (`waiting`, `working`, `gone`, ...) | +| `DISPATCH_SESSION_PREV_STATE` | Prior state, or `none` for a newly seen session | +| `DISPATCH_SESSION_REPO` | Repository | +| `DISPATCH_SESSION_BRANCH` | Branch | +| `DISPATCH_SESSION_FOLDER` | Working directory | +| `DISPATCH_SESSION_SUMMARY` | Session summary | + +Hook output and non-zero exits are written to stderr, and a slow or failing command never stops the watch loop. Example that posts to a webhook when a session starts waiting for input: + +```sh +dispatch watch --exec '[ "$DISPATCH_SESSION_STATE" = waiting ] && \ + curl -s -X POST "$WEBHOOK_URL" -d "session=$DISPATCH_SESSION_ID needs input"' +``` + ### Search (JSON) Query the session store from scripts without opening the TUI. `dispatch search` prints matching sessions as a JSON array by default: diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 8931a16..999e436 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -237,6 +237,7 @@ Watch flags: --repo Only watch sessions for a repository --branch Only watch sessions on a branch --folder Only watch sessions under a folder + --exec Run a command on each attention transition (streaming only) Flags: -h, --help Show this help message diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index b5b0862..cc904fa 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -142,6 +142,7 @@ type manExample struct { var manExamples = []manExample{ {desc: "Launch the TUI filtered to the current repo and branch:", cmd: "dispatch --current"}, {desc: "Export a session as JSON to standard output:", cmd: "dispatch export --format json --stdout"}, + {desc: "Run a command whenever a session changes attention state:", cmd: "dispatch watch --exec 'notify-send \"$DISPATCH_SESSION_STATE\"'"}, {desc: "Install the man page for the current user:", cmd: "dispatch man > ~/.local/share/man/man1/dispatch.1"}, } diff --git a/cmd/dispatch/watch.go b/cmd/dispatch/watch.go index a170690..a93fbf0 100644 --- a/cmd/dispatch/watch.go +++ b/cmd/dispatch/watch.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" + "runtime" "sort" "strings" "time" @@ -28,6 +30,64 @@ var watchListSessionsFn = defaultStatsListSessions // suppress the audible bell. var bellFn = func() { fmt.Fprint(os.Stderr, "\a") } +// watchExecFn runs a hook command with session context supplied through +// environment variables. It is a package variable so tests can capture +// invocations without spawning a shell. +var watchExecFn = runWatchHook + +// runWatchHook executes command through the platform shell, appending sessionEnv +// to the current process environment. Session metadata reaches the command only +// through DISPATCH_SESSION_* variables and is never interpolated into the +// command string, so untrusted session data cannot change what runs. Command +// output is routed to stderr to keep stdout clean for --json consumers. +func runWatchHook(command string, sessionEnv []string) error { + shellPath, flag := hookShell() + cmd := exec.CommandContext(context.Background(), shellPath, flag, command) + cmd.Env = append(os.Environ(), sessionEnv...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// hookShell returns the shell binary and command flag used to run watch hooks +// on the current OS. +func hookShell() (shellPath, flag string) { + if runtime.GOOS == "windows" { + if comspec := os.Getenv("COMSPEC"); comspec != "" { + return comspec, "/c" + } + return "cmd.exe", "/c" + } + if sh := os.Getenv("SHELL"); sh != "" { + return sh, "-c" + } + return "/bin/sh", "-c" +} + +// hookEnv builds the DISPATCH_SESSION_* environment for a transition. state is +// the new attention state; prevState is the prior state ("none" for a session +// seen for the first time). meta carries the session's repository, branch, +// folder, and summary when available. +func hookEnv(id, state, prevState string, meta data.Session) []string { + return []string{ + "DISPATCH_SESSION_ID=" + id, + "DISPATCH_SESSION_STATE=" + state, + "DISPATCH_SESSION_PREV_STATE=" + prevState, + "DISPATCH_SESSION_REPO=" + meta.Repository, + "DISPATCH_SESSION_BRANCH=" + meta.Branch, + "DISPATCH_SESSION_FOLDER=" + meta.Cwd, + "DISPATCH_SESSION_SUMMARY=" + meta.Summary, + } +} + +// fireWatchHook runs the configured hook command for one transition. Any error +// is written to stderr; the watch loop continues regardless. +func fireWatchHook(command, id, state, prevState string, meta data.Session) { + if err := watchExecFn(command, hookEnv(id, state, prevState, meta)); err != nil { + fmt.Fprintf(os.Stderr, "watch hook failed for %s: %v\n", shortID(id), err) + } +} + // watchOptions holds parsed flags for the watch command. type watchOptions struct { once bool @@ -36,6 +96,7 @@ type watchOptions struct { repo string branch string folder string + exec string } // watchSnapshot is the JSON representation of attention state at a point in time. @@ -126,6 +187,12 @@ func parseWatchArgs(args []string) (watchOptions, error) { return watchOptions{}, fmt.Errorf("--folder requires a value") } opts.folder = rest[i] + case arg == "--exec": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--exec requires a command") + } + opts.exec = rest[i] case strings.HasPrefix(arg, "-"): return watchOptions{}, fmt.Errorf("unknown flag: %s", arg) default: @@ -134,6 +201,10 @@ func parseWatchArgs(args []string) (watchOptions, error) { i++ } + if opts.exec != "" && opts.once { + return watchOptions{}, fmt.Errorf("--exec cannot be combined with --once (hooks run only in streaming mode)") + } + return opts, nil } @@ -188,6 +259,10 @@ func runWatchStream(w io.Writer, opts watchOptions) error { return nil case <-ticker.C: current := scanFiltered(opts) + var meta map[string]data.Session + if opts.exec != "" { + meta = watchSessionMeta(opts) + } for id, status := range current { old, existed := prev[id] if !existed || old != status { @@ -205,6 +280,13 @@ func runWatchStream(w io.Writer, opts watchOptions) error { } else { fmt.Fprintf(w, "[%s] %s %s\n", ts, shortID(id), status.String()) } + if opts.exec != "" { + prevState := "none" + if existed { + prevState = old.String() + } + fireWatchHook(opts.exec, id, status.String(), prevState, meta[id]) + } } } // Detect sessions that disappeared. @@ -221,6 +303,9 @@ func runWatchStream(w io.Writer, opts watchOptions) error { } else { fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id)) } + if opts.exec != "" { + fireWatchHook(opts.exec, id, "gone", prev[id].String(), meta[id]) + } } } prev = current @@ -260,6 +345,25 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus { return filtered } +// watchSessionMeta returns a map from session ID to session metadata, honoring +// the same filters as the attention scan. A lookup error yields an empty map so +// a metadata failure never stops the watch loop. +func watchSessionMeta(opts watchOptions) map[string]data.Session { + sessions, err := watchListSessionsFn(data.FilterOptions{ + Repository: opts.repo, + Branch: opts.branch, + Folder: opts.folder, + }) + if err != nil { + return map[string]data.Session{} + } + meta := make(map[string]data.Session, len(sessions)) + for _, s := range sessions { + meta[s.ID] = s + } + return meta +} + // buildWatchSnapshot creates a snapshot of the current attention state. func buildWatchSnapshot(opts watchOptions) (watchSnapshot, error) { attention := scanFiltered(opts) diff --git a/cmd/dispatch/watch_exec_test.go b/cmd/dispatch/watch_exec_test.go new file mode 100644 index 0000000..6e3d320 --- /dev/null +++ b/cmd/dispatch/watch_exec_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "errors" + "runtime" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" +) + +func envToMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok { + m[k] = v + } + } + return m +} + +func TestParseWatchArgs_Exec(t *testing.T) { + opts, err := parseWatchArgs([]string{"watch", "--exec", "notify-send hi"}) + if err != nil { + t.Fatalf("parseWatchArgs: %v", err) + } + if opts.exec != "notify-send hi" { + t.Errorf("exec = %q, want %q", opts.exec, "notify-send hi") + } +} + +func TestParseWatchArgs_ExecMissingValue(t *testing.T) { + _, err := parseWatchArgs([]string{"watch", "--exec"}) + if err == nil { + t.Fatal("expected error for --exec with no value") + } +} + +func TestParseWatchArgs_ExecWithOnceRejected(t *testing.T) { + _, err := parseWatchArgs([]string{"watch", "--exec", "echo hi", "--once"}) + if err == nil { + t.Fatal("expected error when --exec is combined with --once") + } +} + +func TestHookEnv(t *testing.T) { + meta := data.Session{ + Repository: "jongio/dispatch", + Branch: "main", + Cwd: "/home/u/dispatch", + Summary: "fixing watch", + } + env := hookEnv("abc123", "waiting", "working", meta) + got := envToMap(env) + + want := map[string]string{ + "DISPATCH_SESSION_ID": "abc123", + "DISPATCH_SESSION_STATE": "waiting", + "DISPATCH_SESSION_PREV_STATE": "working", + "DISPATCH_SESSION_REPO": "jongio/dispatch", + "DISPATCH_SESSION_BRANCH": "main", + "DISPATCH_SESSION_FOLDER": "/home/u/dispatch", + "DISPATCH_SESSION_SUMMARY": "fixing watch", + } + for k, v := range want { + if got[k] != v { + t.Errorf("env[%s] = %q, want %q", k, got[k], v) + } + } +} + +func TestFireWatchHook_InvokesExec(t *testing.T) { + var gotCmd string + var gotEnv []string + prev := watchExecFn + watchExecFn = func(command string, env []string) error { + gotCmd = command + gotEnv = env + return nil + } + t.Cleanup(func() { watchExecFn = prev }) + + meta := data.Session{Repository: "o/r", Branch: "b", Cwd: "/w", Summary: "s"} + fireWatchHook("do-thing", "sess1", "waiting", "none", meta) + + if gotCmd != "do-thing" { + t.Errorf("command = %q, want %q", gotCmd, "do-thing") + } + got := envToMap(gotEnv) + if got["DISPATCH_SESSION_ID"] != "sess1" { + t.Errorf("DISPATCH_SESSION_ID = %q, want sess1", got["DISPATCH_SESSION_ID"]) + } + if got["DISPATCH_SESSION_PREV_STATE"] != "none" { + t.Errorf("DISPATCH_SESSION_PREV_STATE = %q, want none", got["DISPATCH_SESSION_PREV_STATE"]) + } +} + +func TestFireWatchHook_ErrorDoesNotBlock(t *testing.T) { + prev := watchExecFn + watchExecFn = func(string, []string) error { return errors.New("boom") } + t.Cleanup(func() { watchExecFn = prev }) + + // A failing hook must not panic; the error is written to stderr. + fireWatchHook("bad", "id", "waiting", "none", data.Session{}) +} + +func TestHookShell(t *testing.T) { + shellPath, flag := hookShell() + if shellPath == "" { + t.Fatal("shell path is empty") + } + if runtime.GOOS == "windows" { + if flag != "/c" { + t.Errorf("flag = %q, want /c", flag) + } + } else if flag != "-c" { + t.Errorf("flag = %q, want -c", flag) + } +} + +func TestRunWatchHook_Success(t *testing.T) { + if err := runWatchHook("exit 0", nil); err != nil { + t.Fatalf("expected success, got %v", err) + } +} + +func TestRunWatchHook_NonZeroExit(t *testing.T) { + if err := runWatchHook("exit 1", nil); err == nil { + t.Fatal("expected error for non-zero exit") + } +} + +func TestWatchSessionMeta(t *testing.T) { + sessions := []data.Session{ + {ID: "a", Repository: "o/r", Branch: "main", Cwd: "/w/a", Summary: "sa"}, + {ID: "b", Repository: "o/r2", Branch: "dev", Cwd: "/w/b", Summary: "sb"}, + } + withWatchSeams(t, map[string]data.AttentionStatus{}, sessions) + + meta := watchSessionMeta(watchOptions{}) + if len(meta) != 2 { + t.Fatalf("meta len = %d, want 2", len(meta)) + } + if meta["a"].Cwd != "/w/a" { + t.Errorf("meta[a].Cwd = %q, want /w/a", meta["a"].Cwd) + } + if meta["b"].Branch != "dev" { + t.Errorf("meta[b].Branch = %q, want dev", meta["b"].Branch) + } +}