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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

### Added

- **`dispatch watch --exec <cmd>`**: 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.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>` 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:
Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ Watch flags:
--repo <name> Only watch sessions for a repository
--branch <name> Only watch sessions on a branch
--folder <path> Only watch sessions under a folder
--exec <cmd> Run a command on each attention transition (streaming only)

Flags:
-h, --help Show this help message
Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --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"},
}

Expand Down
104 changes: 104 additions & 0 deletions cmd/dispatch/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"runtime"
"sort"
"strings"
"time"
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
150 changes: 150 additions & 0 deletions cmd/dispatch/watch_exec_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}