feat(04-01): integrate notifications and focus mode into daemon
- Notifier + FocusTimer fields in Daemon, initialized in NewDaemon - processHookEvent notifies on Working -> Needs Input only (D-01) - Focus mode suppresses notifications when active (D-05) - FocusArgs in protocol, "focus" handler in daemon - CLI "vmux focus <minutes>" command - Tests for all notification transitions and focus handler
This commit is contained in:
13
daemon.go
13
daemon.go
@@ -168,6 +168,8 @@ type Daemon struct {
|
|||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
lastHookTime time.Time
|
lastHookTime time.Time
|
||||||
mu sync.Mutex // protects lastHookTime
|
mu sync.Mutex // protects lastHookTime
|
||||||
|
notifier Notifier
|
||||||
|
focus *FocusTimer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDaemon creates a daemon ready to start.
|
// NewDaemon creates a daemon ready to start.
|
||||||
@@ -181,6 +183,8 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
|
|||||||
pollInterval: 5 * time.Second,
|
pollInterval: 5 * time.Second,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
hookPort: 3119,
|
hookPort: 3119,
|
||||||
|
notifier: &ExecNotifier{},
|
||||||
|
focus: &FocusTimer{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +443,15 @@ func (d *Daemon) handleConnection(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
writeResponse(conn, Response{OK: true})
|
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":
|
case "stop":
|
||||||
writeResponse(conn, Response{OK: true})
|
writeResponse(conn, Response{OK: true})
|
||||||
d.Stop()
|
d.Stop()
|
||||||
|
|||||||
116
daemon_test.go
116
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) {
|
func TestDaemonUnknownAction(t *testing.T) {
|
||||||
d := newTestDaemon(t)
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
|||||||
16
hook.go
16
hook.go
@@ -70,8 +70,24 @@ func (d *Daemon) processHookEvent(event HookEvent) {
|
|||||||
return
|
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)
|
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.mu.Lock()
|
||||||
d.lastHookTime = time.Now()
|
d.lastHookTime = time.Now()
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|||||||
29
main.go
29
main.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -100,6 +101,33 @@ func main() {
|
|||||||
}
|
}
|
||||||
fmt.Println("Label set.")
|
fmt.Println("Label set.")
|
||||||
|
|
||||||
|
case "focus":
|
||||||
|
if len(filteredArgs) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: vmux focus <minutes>\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":
|
case "stop":
|
||||||
client := NewClient(sockPath)
|
client := NewClient(sockPath)
|
||||||
resp, err := client.Send(Request{Action: "stop"})
|
resp, err := client.Send(Request{Action: "stop"})
|
||||||
@@ -170,6 +198,7 @@ Commands:
|
|||||||
list List active Claude Code sessions
|
list List active Claude Code sessions
|
||||||
switch <query> Switch to the workspace of the matching session
|
switch <query> Switch to the workspace of the matching session
|
||||||
label <id> <text> Assign a label to a session
|
label <id> <text> Assign a label to a session
|
||||||
|
focus <minutes> Suppress notifications for N minutes
|
||||||
stop Stop the vmux daemon
|
stop Stop the vmux daemon
|
||||||
daemon Run the daemon in foreground (used internally)
|
daemon Run the daemon in foreground (used internally)
|
||||||
|
|
||||||
|
|||||||
12
protocol.go
12
protocol.go
@@ -13,9 +13,10 @@ type Request struct {
|
|||||||
|
|
||||||
// Response is the JSON message sent back by the daemon.
|
// Response is the JSON message sent back by the daemon.
|
||||||
type Response struct {
|
type Response struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Sessions []SessionInfo `json:"sessions,omitempty"`
|
Sessions []SessionInfo `json:"sessions,omitempty"`
|
||||||
|
FocusRemaining float64 `json:"focus_remaining,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionInfo is the wire format for a session in IPC responses.
|
// SessionInfo is the wire format for a session in IPC responses.
|
||||||
@@ -42,3 +43,8 @@ type LabelArgs struct {
|
|||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FocusArgs carries the duration for the focus command.
|
||||||
|
type FocusArgs struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user