diff --git a/daemon.go b/daemon.go index 4dc8415..7b7ac13 100644 --- a/daemon.go +++ b/daemon.go @@ -168,6 +168,8 @@ type Daemon struct { httpServer *http.Server lastHookTime time.Time mu sync.Mutex // protects lastHookTime + notifier Notifier + focus *FocusTimer } // NewDaemon creates a daemon ready to start. @@ -181,6 +183,8 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon pollInterval: 5 * time.Second, stopCh: make(chan struct{}), hookPort: 3119, + notifier: &ExecNotifier{}, + focus: &FocusTimer{}, } } @@ -439,6 +443,15 @@ func (d *Daemon) handleConnection(conn net.Conn) { } writeResponse(conn, Response{OK: true}) + case "focus": + var args FocusArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + writeResponse(conn, Response{Error: "invalid focus args: " + err.Error()}) + return + } + d.focus.Set(time.Duration(args.Minutes) * time.Minute) + writeResponse(conn, Response{OK: true, FocusRemaining: d.focus.Remaining().Minutes()}) + case "stop": writeResponse(conn, Response{OK: true}) d.Stop() diff --git a/daemon_test.go b/daemon_test.go index a894ad2..03ada50 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -432,6 +432,122 @@ func TestDaemonLabelOverSocket(t *testing.T) { } } +// spyNotifier records calls to Notify for test assertions. +type spyNotifier struct { + calls []struct{ title, body string } +} + +func (s *spyNotifier) Notify(title, body string) error { + s.calls = append(s.calls, struct{ title, body string }{title, body}) + return nil +} + +func TestNotification_WorkingToNeedsInput(t *testing.T) { + d := newTestDaemon(t) + spy := &spyNotifier{} + d.notifier = spy + + // Seed session as Working + d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj") + + // Transition to Needs Input + d.processHookEvent(HookEvent{ + SessionID: "sess-1", + HookEventName: "Stop", + Cwd: "/tmp/proj", + }) + + if len(spy.calls) != 1 { + t.Fatalf("notify calls = %d, want 1", len(spy.calls)) + } + if spy.calls[0].title != "vmux: proj" { + t.Errorf("title = %q, want %q", spy.calls[0].title, "vmux: proj") + } +} + +func TestNotification_IdleToNeedsInput(t *testing.T) { + d := newTestDaemon(t) + spy := &spyNotifier{} + d.notifier = spy + + // Seed session as Idle (not Working) + d.registry.UpdateFromHook("sess-1", "Idle", "", "/tmp/proj") + + // Transition to Needs Input from Idle + d.processHookEvent(HookEvent{ + SessionID: "sess-1", + HookEventName: "Stop", + Cwd: "/tmp/proj", + }) + + if len(spy.calls) != 0 { + t.Errorf("notify calls = %d, want 0 (Idle -> Needs Input should not notify)", len(spy.calls)) + } +} + +func TestNotification_FocusActive(t *testing.T) { + d := newTestDaemon(t) + spy := &spyNotifier{} + d.notifier = spy + d.focus.Set(30 * time.Minute) + + // Seed session as Working + d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj") + + // Transition to Needs Input (but focus active) + d.processHookEvent(HookEvent{ + SessionID: "sess-1", + HookEventName: "Stop", + Cwd: "/tmp/proj", + }) + + if len(spy.calls) != 0 { + t.Errorf("notify calls = %d, want 0 (focus active should suppress)", len(spy.calls)) + } +} + +func TestNotification_FocusExpired(t *testing.T) { + d := newTestDaemon(t) + spy := &spyNotifier{} + d.notifier = spy + d.focus.Set(0) // expired immediately + + // Seed session as Working + d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj") + + // Transition to Needs Input (focus expired) + d.processHookEvent(HookEvent{ + SessionID: "sess-1", + HookEventName: "Stop", + Cwd: "/tmp/proj", + }) + + if len(spy.calls) != 1 { + t.Fatalf("notify calls = %d, want 1 (focus expired should notify)", len(spy.calls)) + } +} + +func TestFocusHandler(t *testing.T) { + d := newTestDaemon(t) + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + args, _ := json.Marshal(FocusArgs{Minutes: 30}) + resp := sendRequest(t, d.sockPath, Request{Action: "focus", Args: args}) + if !resp.OK { + t.Fatalf("focus resp.OK = false, error = %q", resp.Error) + } + if !d.focus.IsActive() { + t.Error("focus should be active after handler") + } + if resp.FocusRemaining <= 0 { + t.Errorf("FocusRemaining = %v, want > 0", resp.FocusRemaining) + } +} + func TestDaemonUnknownAction(t *testing.T) { d := newTestDaemon(t) diff --git a/hook.go b/hook.go index eaf97a7..2cf7aa6 100644 --- a/hook.go +++ b/hook.go @@ -70,8 +70,24 @@ func (d *Daemon) processHookEvent(event HookEvent) { return } + // Read PrevState BEFORE UpdateFromHook overwrites it + d.registry.mu.RLock() + prevState := "" + if ts, ok := d.registry.sessions[event.SessionID]; ok { + prevState = ts.PrevState + } + d.registry.mu.RUnlock() + d.registry.UpdateFromHook(event.SessionID, state, waitType, event.Cwd) + // Notify only on Working -> Needs Input transition (D-01) + if state == "Needs Input" && prevState == "Working" && !d.focus.IsActive() { + d.registry.mu.RLock() + info := d.registry.sessions[event.SessionID].Info + d.registry.mu.RUnlock() + d.notifier.Notify("vmux: "+shortName(info), "Session needs input ("+waitType+")") + } + d.mu.Lock() d.lastHookTime = time.Now() d.mu.Unlock() diff --git a/main.go b/main.go index 75fbd1f..35c5141 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strconv" "strings" "time" @@ -100,6 +101,33 @@ func main() { } fmt.Println("Label set.") + case "focus": + if len(filteredArgs) < 2 { + fmt.Fprintf(os.Stderr, "Usage: vmux focus \n") + os.Exit(1) + } + minutes, err := strconv.Atoi(filteredArgs[1]) + if err != nil || minutes < 0 { + fmt.Fprintf(os.Stderr, "Error: invalid minutes %q\n", filteredArgs[1]) + os.Exit(1) + } + if err := EnsureDaemon(sockPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + client := NewClient(sockPath) + focusArgs, _ := json.Marshal(FocusArgs{Minutes: minutes}) + resp, err := client.Send(Request{Action: "focus", Args: focusArgs}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if !resp.OK { + fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) + os.Exit(1) + } + fmt.Printf("Focus mode: notifications suppressed for %d minutes\n", minutes) + case "stop": client := NewClient(sockPath) resp, err := client.Send(Request{Action: "stop"}) @@ -170,6 +198,7 @@ Commands: list List active Claude Code sessions switch Switch to the workspace of the matching session label Assign a label to a session + focus Suppress notifications for N minutes stop Stop the vmux daemon daemon Run the daemon in foreground (used internally) diff --git a/protocol.go b/protocol.go index d2ceb18..f8ba263 100644 --- a/protocol.go +++ b/protocol.go @@ -13,9 +13,10 @@ type Request struct { // Response is the JSON message sent back by the daemon. type Response struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Sessions []SessionInfo `json:"sessions,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Sessions []SessionInfo `json:"sessions,omitempty"` + FocusRemaining float64 `json:"focus_remaining,omitempty"` } // SessionInfo is the wire format for a session in IPC responses. @@ -42,3 +43,8 @@ type LabelArgs struct { SessionID string `json:"session_id"` Label string `json:"label"` } + +// FocusArgs carries the duration for the focus command. +type FocusArgs struct { + Minutes int `json:"minutes"` +}