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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,11 @@ 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 --once --status waiting # filter by attention status
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.
The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, `--folder`, or `--status waiting|working|thinking|active|stale|compacting|interrupted|idle` to limit which sessions are monitored.

#### Transition hooks

Expand Down
102 changes: 77 additions & 25 deletions cmd/dispatch/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ func fireWatchHook(command, id, state, prevState string, meta data.Session) {

// watchOptions holds parsed flags for the watch command.
type watchOptions struct {
once bool
json bool
interval time.Duration
repo string
branch string
folder string
exec string
once bool
json bool
interval time.Duration
repo string
branch string
folder string
exec string
hasStatus bool
status data.AttentionStatus
}

// watchSnapshot is the JSON representation of attention state at a point in time.
Expand Down Expand Up @@ -193,6 +195,17 @@ func parseWatchArgs(args []string) (watchOptions, error) {
return watchOptions{}, fmt.Errorf("--exec requires a command")
}
opts.exec = rest[i]
case arg == "--status":
i++
if i >= len(rest) {
return watchOptions{}, fmt.Errorf("--status requires a value")
}
status, sErr := parseWatchStatus(rest[i])
if sErr != nil {
return watchOptions{}, sErr
}
opts.status = status
opts.hasStatus = true
case strings.HasPrefix(arg, "-"):
return watchOptions{}, fmt.Errorf("unknown flag: %s", arg)
default:
Expand All @@ -208,6 +221,29 @@ func parseWatchArgs(args []string) (watchOptions, error) {
return opts, nil
}

func parseWatchStatus(value string) (data.AttentionStatus, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "waiting":
return data.AttentionWaiting, nil
case "working":
return data.AttentionWorking, nil
case "thinking":
return data.AttentionThinking, nil
case "active":
return data.AttentionActive, nil
case "stale":
return data.AttentionStale, nil
case "compacting":
return data.AttentionCompacting, nil
case "interrupted":
return data.AttentionInterrupted, nil
case "idle":
return data.AttentionIdle, nil
default:
return data.AttentionIdle, fmt.Errorf("invalid --status value %q (want waiting, working, thinking, active, stale, compacting, interrupted, or idle)", value)
}
}

// runWatchOnce prints a single attention snapshot and exits.
func runWatchOnce(w io.Writer, opts watchOptions) error {
snap, err := buildWatchSnapshot(opts)
Expand Down Expand Up @@ -290,21 +326,23 @@ func runWatchStream(w io.Writer, opts watchOptions) error {
}
}
// Detect sessions that disappeared.
for id := range prev {
if _, ok := current[id]; !ok {
ts := time.Now().Format("15:04:05")
if opts.json {
enc := json.NewEncoder(w)
_ = enc.Encode(map[string]string{
"time": ts,
"id": id,
"status": "gone",
})
} else {
fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id))
}
if opts.exec != "" {
fireWatchHook(opts.exec, id, "gone", prev[id].String(), meta[id])
if !opts.hasStatus {
for id := range prev {
if _, ok := current[id]; !ok {
ts := time.Now().Format("15:04:05")
if opts.json {
enc := json.NewEncoder(w)
_ = enc.Encode(map[string]string{
"time": ts,
"id": id,
"status": "gone",
})
} else {
fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id))
}
if opts.exec != "" {
fireWatchHook(opts.exec, id, "gone", prev[id].String(), meta[id])
}
}
}
}
Expand All @@ -319,7 +357,7 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus {
attention := watchScanAttentionFn(threshold)

if opts.repo == "" && opts.branch == "" && opts.folder == "" {
return attention
return filterAttentionByStatus(attention, opts)
}

sessions, err := watchListSessionsFn(data.FilterOptions{
Expand All @@ -328,7 +366,7 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus {
Folder: opts.folder,
})
if err != nil {
return attention
return filterAttentionByStatus(attention, opts)
}

allowed := make(map[string]bool, len(sessions))
Expand All @@ -342,6 +380,19 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus {
filtered[id] = status
}
}
return filterAttentionByStatus(filtered, opts)
}

func filterAttentionByStatus(attention map[string]data.AttentionStatus, opts watchOptions) map[string]data.AttentionStatus {
if !opts.hasStatus {
return attention
}
filtered := make(map[string]data.AttentionStatus, len(attention))
for id, status := range attention {
if status == opts.status {
filtered[id] = status
}
}
return filtered
}

Expand Down Expand Up @@ -408,7 +459,8 @@ func buildWatchSnapshot(opts watchOptions) (watchSnapshot, error) {
snap.Idle++
}

if status == data.AttentionWaiting || status == data.AttentionInterrupted ||
if opts.hasStatus ||
status == data.AttentionWaiting || status == data.AttentionInterrupted ||
status == data.AttentionWorking || status == data.AttentionThinking ||
status == data.AttentionActive {
entries = append(entries, watchSessionEntry{
Expand Down
50 changes: 50 additions & 0 deletions cmd/dispatch/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ func TestRunWatch_FilteredByRepo(t *testing.T) {
}
}

func TestRunWatch_FilteredByStatus(t *testing.T) {
attention := map[string]data.AttentionStatus{
"ses-1": data.AttentionWaiting,
"ses-2": data.AttentionIdle,
"ses-3": data.AttentionWaiting,
}
sessions := []data.Session{
{ID: "ses-1", Summary: "Auth"},
{ID: "ses-2", Summary: "Quiet"},
{ID: "ses-3", Summary: "Review"},
}
withWatchSeams(t, attention, sessions)

var buf bytes.Buffer
if err := runWatch(&buf, []string{"watch", "--once", "--json", "--status", "waiting"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got watchSnapshot
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Total != 2 || got.Waiting != 2 || got.Idle != 0 {
t.Fatalf("snapshot = %+v, want two waiting sessions only", got)
}
if len(got.Sessions) != 2 {
t.Fatalf("sessions len = %d, want 2", len(got.Sessions))
}
}

func TestParseWatchArgs_Interval(t *testing.T) {
opts, err := parseWatchArgs([]string{"watch", "--interval", "10s"})
if err != nil {
Expand All @@ -173,6 +202,16 @@ func TestParseWatchArgs_IntervalClamped(t *testing.T) {
}
}

func TestParseWatchArgs_Status(t *testing.T) {
opts, err := parseWatchArgs([]string{"watch", "--status", "Interrupted"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !opts.hasStatus || opts.status != data.AttentionInterrupted {
t.Fatalf("status filter = %v %v, want interrupted", opts.hasStatus, opts.status)
}
}

func TestParseWatchArgs_UnknownFlag(t *testing.T) {
_, err := parseWatchArgs([]string{"watch", "--nope"})
if err == nil {
Expand All @@ -187,6 +226,17 @@ func TestParseWatchArgs_MissingIntervalValue(t *testing.T) {
}
}

func TestParseWatchArgs_StatusErrors(t *testing.T) {
for _, args := range [][]string{
{"watch", "--status"},
{"watch", "--status", "blocked"},
} {
if _, err := parseWatchArgs(args); err == nil {
t.Fatalf("parseWatchArgs(%v) returned nil error", args)
}
}
}

func TestRunWatch_ListSessionsError(t *testing.T) {
prevScan := watchScanAttentionFn
watchScanAttentionFn = func(time.Duration) map[string]data.AttentionStatus {
Expand Down