From 54125eb5738aa0303d9194c4b11265dd832cdb7a Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 14:12:58 +0200 Subject: [PATCH 01/13] fix(ipc): non-blocking broadcast + 4 KiB per-event size cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the wedge incident class. One stuck IPC client (slow TUI render, crashed MCP bridge, kernel socket buffer congestion) can no longer stall the daemon's broadcast fan-out for every other client. internal/ipc/server.go — replace the synchronous Broadcast with a per-Conn 64-slot buffered send queue and a dedicated send goroutine per conn. Send is non-blocking: if the queue overflows the offending conn is scheduled for async close and ErrSendOverflow surfaces in the broadcast log, but other conns keep receiving. Broadcast also marshals the wire frame once and shares the bytes across the per-conn queues instead of re-serializing per client. Close is sync.Once-guarded so the read and write halves can race to it safely. internal/daemon/event.go — enforce a 4 KiB cap on PaneEvent.Message and 128-byte cap per Data value at the IPC boundary (toPaneEventPayload). Truncation keeps the tail (most recent / terminal-visible content) and sets Data["truncated"]="1" so consumers can flag the event. The earlier opencode-splash wedge produced a > 1 KiB box-drawing excerpt that flooded the broadcast loop; this is the size-side defense. Tests: - TestBroadcast_SlowConnDoesNotBlockFastConn — 200 broadcasts with one conn refusing to read; assert the Broadcast loop returns within 1 s and the fast conn drains. - TestBroadcast_ContinuesAfterSlowConnDisconnects — after the slow conn is torn down, new broadcasts still reach the surviving conn. - TestToPaneEventPayload_* — truncation marker placement, tail preservation, under-cap pass-through, nil-Data no-allocation path. All green under -race. --- internal/daemon/event.go | 54 ++++++- internal/daemon/event_sizecap_test.go | 101 +++++++++++++ internal/ipc/broadcast_resilience_test.go | 174 ++++++++++++++++++++++ internal/ipc/server.go | 129 ++++++++++++++-- 4 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 internal/daemon/event_sizecap_test.go create mode 100644 internal/ipc/broadcast_resilience_test.go diff --git a/internal/daemon/event.go b/internal/daemon/event.go index 44d1f46..1c479aa 100644 --- a/internal/daemon/event.go +++ b/internal/daemon/event.go @@ -146,8 +146,56 @@ func (q *eventQueue) RemoveWatchersByConn(conn *ipc.Conn) { q.watchers = remaining } -// toPaneEventPayload converts a PaneEvent to an IPC payload. +// Per-event wire-size caps. The earlier wedge incident happened with a +// > 1 KiB box-drawing excerpt from an opencode splash screen flooding the +// IPC fan-out. 4 KiB per Message and 128 bytes per Data value give comfortable +// headroom for legitimate content (multi-line excerpts, command previews, +// error stacks) while keeping a runaway event source from bloating the wire. +// +// Truncation is from the *front* so the most recent (and usually most +// relevant) content survives — for excerpts that's the last visible line a +// terminal would actually display. +const ( + maxEventMessageBytes = 4 * 1024 + maxEventDataValueBytes = 128 + truncationMarker = "…[truncated]" +) + +// toPaneEventPayload converts a PaneEvent to an IPC payload, enforcing the +// per-event wire-size caps. Caps are applied at the IPC boundary so all +// emitters (idle checker, bell, process_exit, future hook events) share the +// same protection. func toPaneEventPayload(e PaneEvent) ipc.PaneEventPayload { + message := e.Message + truncated := false + + if len(message) > maxEventMessageBytes { + // Keep the tail — for excerpts, the bottom-most lines are the + // terminal-visible ones; for error blobs the trailing lines often + // carry the actual cause. + message = truncationMarker + message[len(message)-(maxEventMessageBytes-len(truncationMarker)):] + truncated = true + } + + var data map[string]string + if len(e.Data) > 0 { + data = make(map[string]string, len(e.Data)) + for k, v := range e.Data { + if len(v) > maxEventDataValueBytes { + data[k] = v[:maxEventDataValueBytes-len(truncationMarker)] + truncationMarker + truncated = true + } else { + data[k] = v + } + } + } + if truncated { + if data == nil { + data = make(map[string]string, 1) + } + data["truncated"] = "1" + } + return ipc.PaneEventPayload{ ID: e.ID, PaneID: e.PaneID, @@ -155,9 +203,9 @@ func toPaneEventPayload(e PaneEvent) ipc.PaneEventPayload { PaneName: e.PaneName, Type: e.Type, Title: e.Title, - Message: e.Message, + Message: message, Severity: e.Severity, Timestamp: e.Timestamp.UnixMilli(), - Data: e.Data, + Data: data, } } diff --git a/internal/daemon/event_sizecap_test.go b/internal/daemon/event_sizecap_test.go new file mode 100644 index 0000000..1e500ec --- /dev/null +++ b/internal/daemon/event_sizecap_test.go @@ -0,0 +1,101 @@ +package daemon + +import ( + "strings" + "testing" + "time" +) + +// TestToPaneEventPayload_MessageOverCapTruncatesAndMarks proves the wedge- +// prevention contract: any single event Message larger than the cap is +// truncated from the front, the trailing visible content is preserved, the +// payload size lands under the cap, and the Data map carries a "truncated" +// flag for consumers that want to know. +func TestToPaneEventPayload_MessageOverCapTruncatesAndMarks(t *testing.T) { + // 8 KiB Message — double the cap. + original := strings.Repeat("x", 4*1024) + "TAIL_VISIBLE" + ev := PaneEvent{ + ID: "evt-1", + Title: "Output idle", + Message: original, + Severity: "info", + Timestamp: time.Unix(0, 0), + } + + got := toPaneEventPayload(ev) + + if len(got.Message) > maxEventMessageBytes { + t.Errorf("Message len after cap: got %d, want ≤ %d", len(got.Message), maxEventMessageBytes) + } + if !strings.HasPrefix(got.Message, truncationMarker) { + t.Errorf("Message should be prefixed with truncation marker; got first 32 chars %q", got.Message[:32]) + } + if !strings.HasSuffix(got.Message, "TAIL_VISIBLE") { + t.Errorf("Message should preserve the tail (most recent content) after truncation; got suffix %q", got.Message[len(got.Message)-20:]) + } + if got.Data["truncated"] != "1" { + t.Errorf("Data[\"truncated\"] = %q, want \"1\"", got.Data["truncated"]) + } +} + +// TestToPaneEventPayload_DataValueOverCapTruncates verifies the per-value cap +// catches misbehaving sources that stuff long strings into Data (e.g. a hook +// dumping a full prompt or a stack trace). +func TestToPaneEventPayload_DataValueOverCapTruncates(t *testing.T) { + bigValue := strings.Repeat("y", 1024) + ev := PaneEvent{ + ID: "evt-1", + Title: "tool_run", + Data: map[string]string{"args": bigValue, "short": "ok"}, + } + + got := toPaneEventPayload(ev) + + if len(got.Data["args"]) > maxEventDataValueBytes { + t.Errorf("Data[args] len: got %d, want ≤ %d", len(got.Data["args"]), maxEventDataValueBytes) + } + if !strings.HasSuffix(got.Data["args"], truncationMarker) { + t.Errorf("oversize Data value should be suffixed with truncation marker; got %q", got.Data["args"]) + } + if got.Data["short"] != "ok" { + t.Errorf("small Data values must pass through untouched; got %q", got.Data["short"]) + } + if got.Data["truncated"] != "1" { + t.Errorf("Data[truncated] = %q, want \"1\"", got.Data["truncated"]) + } +} + +// TestToPaneEventPayload_WithinCapPassesThrough is the happy path — normal +// events must not be modified. +func TestToPaneEventPayload_WithinCapPassesThrough(t *testing.T) { + ev := PaneEvent{ + ID: "evt-1", + PaneID: "pane-1", + Title: "Process exited (code 0)", + Message: "build succeeded\n", + Severity: "info", + Data: map[string]string{"exit_code": "0"}, + } + + got := toPaneEventPayload(ev) + + if got.Message != "build succeeded\n" { + t.Errorf("Message mutated under cap; got %q", got.Message) + } + if got.Data["exit_code"] != "0" { + t.Errorf("Data[exit_code] mutated; got %q", got.Data["exit_code"]) + } + if _, ok := got.Data["truncated"]; ok { + t.Errorf("under-cap event must not carry truncated marker; got Data=%v", got.Data) + } +} + +// TestToPaneEventPayload_NilDataNoTruncationMarker — when nothing is over the +// cap, we never allocate a Data map just to set the marker. +func TestToPaneEventPayload_NilDataNoTruncationMarker(t *testing.T) { + ev := PaneEvent{ID: "evt-1", Title: "ok", Message: "short"} + got := toPaneEventPayload(ev) + if got.Data != nil { + t.Errorf("expected nil Data when no truncation needed; got %v", got.Data) + } +} diff --git a/internal/ipc/broadcast_resilience_test.go b/internal/ipc/broadcast_resilience_test.go new file mode 100644 index 0000000..1e25b4c --- /dev/null +++ b/internal/ipc/broadcast_resilience_test.go @@ -0,0 +1,174 @@ +package ipc_test + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/artyomsv/quil/internal/ipc" +) + +// TestBroadcast_SlowConnDoesNotBlockFastConn proves the wedge defense: when +// one client stops reading from its socket, the daemon's broadcast loop must +// continue serving the healthy clients without delay. The Bubble Tea event +// loop on a connected TUI cannot be allowed to stall the entire daemon. +func TestBroadcast_SlowConnDoesNotBlockFastConn(t *testing.T) { + sockPath := filepath.Join(t.TempDir(), "slow-vs-fast.sock") + + srv := ipc.NewServer(sockPath, func(*ipc.Conn, *ipc.Message) {}, nil) + if err := srv.Start(); err != nil { + t.Fatalf("server start: %v", err) + } + defer srv.Stop() + + fast, err := ipc.NewClient(sockPath) + if err != nil { + t.Fatalf("fast client connect: %v", err) + } + defer fast.Close() + + slow, err := ipc.NewClient(sockPath) + if err != nil { + t.Fatalf("slow client connect: %v", err) + } + defer slow.Close() + + // Wait for the server to register both connections. + time.Sleep(100 * time.Millisecond) + + // Slow client deliberately never reads — its kernel socket buffer fills up, + // then the daemon's 64-slot per-conn queue overflows, then the daemon + // closes the slow conn. Meanwhile the fast client must keep receiving. + + const broadcasts = 200 + const fastReceives = 50 + + // Drain fast client in the background to a counter, so we can assert it + // got messages quickly without blocking. + gotFast := make(chan int, broadcasts) + go func() { + count := 0 + for { + if _, err := fast.Receive(); err != nil { + close(gotFast) + return + } + count++ + gotFast <- count + } + }() + + // Build a 4 KiB-ish payload so each broadcast meaningfully exercises the + // per-conn send queue. Pure echo of an arbitrary string. + payload := map[string]string{"data": string(make([]byte, 4000))} + + broadcastStart := time.Now() + for i := 0; i < broadcasts; i++ { + msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, payload) + srv.Broadcast(msg) + } + broadcastDur := time.Since(broadcastStart) + + // All Broadcast calls must return promptly even though one peer is + // stalled. 1s gives plenty of headroom for CI jitter; the actual cost + // is microseconds. + if broadcastDur > time.Second { + t.Errorf("Broadcast loop blocked: %d broadcasts took %v (want < 1s) — slow client wedged the fan-out", broadcasts, broadcastDur) + } + + // Fast client must drain enough messages to demonstrate it's still being + // served. Healthy peers never stall. + timeout := time.After(3 * time.Second) + for { + select { + case n, ok := <-gotFast: + if !ok { + t.Fatal("fast client got an error before reaching the expected message count") + } + if n >= fastReceives { + return // success + } + case <-timeout: + t.Fatalf("fast client only drained partway within 3s — broadcast fan-out may be wedged") + } + } +} + +// TestBroadcast_ContinuesAfterSlowConnDisconnects covers the post-overflow +// state: after the slow conn is torn down, broadcasts to remaining conns +// continue normally. Uses small payloads + a 1 ms pacing gap so the fast +// client's drain goroutine reliably keeps up even under race-detector +// slowdown. +func TestBroadcast_ContinuesAfterSlowConnDisconnects(t *testing.T) { + sockPath := filepath.Join(t.TempDir(), "post-overflow.sock") + + srv := ipc.NewServer(sockPath, func(*ipc.Conn, *ipc.Message) {}, nil) + if err := srv.Start(); err != nil { + t.Fatalf("server start: %v", err) + } + defer srv.Stop() + + fast, err := ipc.NewClient(sockPath) + if err != nil { + t.Fatalf("fast client: %v", err) + } + defer fast.Close() + + slow, err := ipc.NewClient(sockPath) + if err != nil { + t.Fatalf("slow client: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + var fastCount int + var fastMu sync.Mutex + go func() { + for { + if _, err := fast.Receive(); err != nil { + return + } + fastMu.Lock() + fastCount++ + fastMu.Unlock() + } + }() + + // Paced burst: enough to overflow the non-reading slow conn but slow + // enough that the fast drain goroutine never falls behind. + smallPayload := map[string]string{"kind": "tick"} + for i := 0; i < 150; i++ { + msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, smallPayload) + srv.Broadcast(msg) + time.Sleep(time.Millisecond) + } + + // Let the overflow tear down the slow conn. + time.Sleep(200 * time.Millisecond) + slow.Close() + time.Sleep(100 * time.Millisecond) + + fastMu.Lock() + pre := fastCount + fastMu.Unlock() + + // Issue NEW broadcasts after the slow conn is torn down. Fast must still + // see them — the absence of slow in the broadcast fan-out is the + // post-overflow invariant we care about. + for i := 0; i < 50; i++ { + msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, smallPayload) + srv.Broadcast(msg) + time.Sleep(time.Millisecond) + } + + time.Sleep(300 * time.Millisecond) + + fastMu.Lock() + post := fastCount + fastMu.Unlock() + + if post-pre < 30 { + t.Errorf("after slow conn disconnect, fast client only got %d new messages (want ≥ 30)", post-pre) + } +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go index eec0654..91dd288 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -1,29 +1,108 @@ package ipc import ( + "bytes" + "errors" "log" "net" "os" "sync" + "sync/atomic" ) +// sendBufSize is the per-connection queue depth. A wedged or slow client can +// build up at most this many in-flight frames before the server marks the +// connection as overflowed and tears it down — guaranteeing that one bad +// client cannot block the daemon's broadcast loop and starve healthy peers. +// +// 64 frames is comfortably more than any healthy client lags by (a TUI's +// Bubble Tea event loop typically drains everything in <50 ms) and small +// enough that an overflowed conn is detected within a few milliseconds. +const sendBufSize = 64 + +// ErrSendOverflow is returned by Conn.Send when the per-conn send buffer is +// full. The connection has been scheduled for close; future Sends short- +// circuit with the same error. +var ErrSendOverflow = errors.New("ipc: send buffer overflow (slow client)") + // MessageHandler is called for each incoming message on a connection. type MessageHandler func(conn *Conn, msg *Message) // Conn wraps a net.Conn with message framing. +// +// Sends are non-blocking: each Conn owns a 64-slot queue and a dedicated +// goroutine that drains the queue into the underlying socket. A slow or +// wedged peer drains its own queue; if the queue overflows, the offending +// conn is closed in the background and Send returns ErrSendOverflow. Other +// connections are never affected by one client's slowness — closing the +// wedge-incident class where a single stuck TUI or MCP bridge stalled the +// daemon's broadcast for every other client. type Conn struct { - raw net.Conn - mu sync.Mutex + raw net.Conn + sendCh chan []byte + done chan struct{} + closeOnce sync.Once + closed atomic.Bool + overflow atomic.Bool } func newConn(raw net.Conn) *Conn { - return &Conn{raw: raw} + c := &Conn{ + raw: raw, + sendCh: make(chan []byte, sendBufSize), + done: make(chan struct{}), + } + go c.sendLoop() + return c } +// Send marshals msg into the wire frame and queues it for transmission. Returns +// ErrSendOverflow when the per-conn buffer is full — the conn has been +// scheduled for async close at that point. func (c *Conn) Send(msg *Message) error { - c.mu.Lock() - defer c.mu.Unlock() - return WriteMessage(c.raw, msg) + if c.closed.Load() || c.overflow.Load() { + return ErrSendOverflow + } + var buf bytes.Buffer + if err := WriteMessage(&buf, msg); err != nil { + return err + } + return c.sendFrame(buf.Bytes()) +} + +// sendFrame queues a pre-encoded wire frame. Used by Broadcast to share one +// marshal allocation across N conns. The frame []byte is read-only — both +// sendFrame and sendLoop only read it, never mutate it. +func (c *Conn) sendFrame(frame []byte) error { + if c.closed.Load() || c.overflow.Load() { + return ErrSendOverflow + } + select { + case c.sendCh <- frame: + return nil + default: + // Buffer full — slow client. Tear it down asynchronously so the + // broadcaster never blocks on the close, and short-circuit all + // future Sends. + c.overflow.Store(true) + go c.Close() + return ErrSendOverflow + } +} + +func (c *Conn) sendLoop() { + for { + select { + case <-c.done: + return + case frame := <-c.sendCh: + if _, err := c.raw.Write(frame); err != nil { + // Peer gone or socket error — exit. The read side will see + // the matching error and clean up via handleConn's defer. + return + } + } + } } func (c *Conn) Receive() (*Message, error) { @@ -31,7 +110,13 @@ func (c *Conn) Receive() (*Message, error) { } func (c *Conn) Close() error { - return c.raw.Close() + var err error + c.closeOnce.Do(func() { + c.closed.Store(true) + close(c.done) + err = c.raw.Close() + }) + return err } // Server listens for client connections over a Unix socket. @@ -78,13 +163,33 @@ func (s *Server) Stop() error { return s.listener.Close() } -// Broadcast sends a message to all connected clients. +// Broadcast sends a message to all connected clients without blocking on any +// individual conn. Marshals the wire frame once and shares the bytes across +// all per-conn send queues. A slow or wedged conn is dropped from the fan-out +// (logged once) without affecting the others. func (s *Server) Broadcast(msg *Message) { + var buf bytes.Buffer + if err := WriteMessage(&buf, msg); err != nil { + log.Printf("broadcast marshal: %v", err) + return + } + frame := buf.Bytes() + + // Snapshot the conns list under the lock so the per-conn sendFrame calls + // below run lock-free — no risk of a slow send chain interleaving with + // accept/disconnect bookkeeping. s.mu.Lock() - defer s.mu.Unlock() - for _, c := range s.conns { - if err := c.Send(msg); err != nil { - log.Printf("broadcast send: %v", err) + conns := make([]*Conn, len(s.conns)) + copy(conns, s.conns) + s.mu.Unlock() + + for _, c := range conns { + if err := c.sendFrame(frame); err != nil { + if errors.Is(err, ErrSendOverflow) { + log.Printf("ipc: dropping slow client (send buffer overflow)") + } else { + log.Printf("broadcast send: %v", err) + } } } } From 8b177af1f222df91ff432ce52958b95d19f55a69 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 14:34:30 +0200 Subject: [PATCH 02/13] fix(ipc): address review findings on PR #40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply all Critical / High / Medium / Low items surfaced by the code review. Source changes: - internal/ipc/server.go * (C1) Broadcast now clones the marshaled frame via slices.Clone before fan-out. Today no callsite mutates it, but a future sync.Pool of bytes.Buffer would silently corrupt frames still being read by per-conn sendLoops; the clone makes that contract bullet-proof. * (L4 + M3) Overflow path is CAS-guarded — the warning log fires exactly once per slow conn and only one Close goroutine spawns, instead of N redundant log lines and N redundant goroutines while the conn is being reaped. * (H1 belt-and-suspenders) sendLoop sets a 30 s SetWriteDeadline per frame so a kernel-buffer wedge that doesn't error has a deterministic cleanup ceiling. * (M4) log.Printf migrated to logger.Warn / logger.Error so the project's leveled logger controls visibility. * (M2) Document why Send and sendFrame both check closed/overflow — outer check is the marshal-cost fast path, inner check is the race-safe gate next to the channel send. * (H2) Close() doc comment clarifies pending frames are intentionally discarded. * (L6) Guard comment on the conns snapshot copy in Broadcast warns against future "optimizations" that would reintroduce the wedge. * (M6) Server.Stop comment clarifies Daemon shutdown does not rely on a final IPC broadcast (durability lives in the on-disk workspace.json path). * New: Server.ConnCount() for test-friendly connect/disconnect sync. - internal/daemon/event.go * (H4) Reserved Data key renamed to "_quil_truncated" — the new _quil_ namespace prevents accidental collision with caller-supplied keys (including a literal "truncated" key that emitters may use). * (H3) Compile-time invariant block asserts both caps strictly exceed len(truncationMarker); a future contributor lowering either cap below the marker length will fail to build instead of panic at runtime. * (Rules #1) const block reflowed; gofmt-clean. * (L1) Marker length explained (… is 3-byte UTF-8 + "[truncated]" = 14 bytes, not 12). * (L2) Truncation strategy doc comment names the tail-keep rationale and points future emitters at TruncationStrategy if they need head. * (L3) TODO comment on sync.Pool trade-off (defer until profiling demands it; clone-then-pool is the correct sequence). Test changes: - internal/ipc/conn_internal_test.go (NEW, package ipc white-box) * TestSendLoop_ExitsOnWriteError — uses net.Pipe to force a write error mid-frame; asserts no goroutine leak (QA #1 finding). * TestConn_CloseIdempotent — 16 concurrent Close calls, none panic, closed flag set exactly once. * TestConn_SendFrameAfterCloseShortCircuits — sendFrame and Send after Close return ErrSendOverflow without touching the socket. - internal/ipc/broadcast_resilience_test.go * (M5) Replaced time.Sleep-after-connect with waitForConnCount polling on the new ConnCount() accessor. No more CI flake risk from racing the connect-registration window. * (Rules #4) Added t.Parallel() to both tests. * (L7) TestBroadcast_MarshalErrorLogsAndReturns covers the previously-untested marshal-error path. - internal/daemon/event_sizecap_test.go * Updated existing 4 tests for the renamed _quil_truncated key. * Added t.Parallel() to all 7 tests. * (QA #2) TestToPaneEventPayload_ExactCapBoundary verifies the > cap condition rejects an off-by-one refactor to >=. * (L8) TestToPaneEventPayload_BothOverCapSetsFlagOnce — when both Message and Data value exceed cap, the flag is set exactly once. * NEW TestToPaneEventPayload_ReservedKeyDoesNotClobberCaller proves the _quil_ namespace fix: a caller's literal "truncated" key survives untouched alongside the daemon's _quil_truncated flag. CLAUDE.md: `internal/ipc/` bullet expanded with the resilience design (sendCh, sendLoop, CAS overflow, ConnCount, size caps) per documentation-maintenance rule. NewClient audit (cmd/quil/main.go, mcp.go, version_gate.go): all callsites either defer Close or explicitly close. SetWriteDeadline in sendLoop is the runtime safety net for future regressions. All green under -race. --- .claude/CLAUDE.md | 2 +- internal/daemon/event.go | 42 +++++-- internal/daemon/event_sizecap_test.go | 108 +++++++++++++++++- internal/ipc/broadcast_resilience_test.go | 127 ++++++++++++++++++---- internal/ipc/conn_internal_test.go | 123 +++++++++++++++++++++ internal/ipc/server.go | 110 +++++++++++++++---- 6 files changed, 451 insertions(+), 61 deletions(-) create mode 100644 internal/ipc/conn_internal_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1f1319d..1563e1d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -22,7 +22,7 @@ Client-daemon model: - `cmd/quild/` — Background daemon - `internal/config/` — TOML configuration (`Load` reads, `Save` writes atomically via `.tmp` + rename). `UIConfig.ShowDisclaimer` controls startup beta dialog - `internal/daemon/` — Session manager, message routing, event queue (`event.go` — bounded, mutex-protected, watcher pub/sub for MCP) -- `internal/ipc/` — Length-prefixed JSON protocol (4-byte big-endian uint32 + JSON) +- `internal/ipc/` — Length-prefixed JSON protocol (4-byte big-endian uint32 + JSON). Each `Conn` owns a 64-slot send buffer (`sendCh`) drained by a dedicated `sendLoop` goroutine; `Send`/`sendFrame` are non-blocking. `Broadcast` marshals once and shares the cloned wire frame across N conns lock-free after snapshotting `s.conns`. A slow / wedged peer's `sendFrame` trips a CAS-guarded overflow that logs once + spawns `go c.Close()` (`ErrSendOverflow`); other clients never block. `sendLoop` enforces a 30 s `SetWriteDeadline` per frame as a belt-and-suspenders catch for kernel-buffer wedges. `Server.ConnCount()` exposes the live count for tests. Daemon-side per-event size caps (4 KiB Message, 128 B per Data value, front-truncated with `…[truncated]` marker, reserved `_quil_truncated` flag) live in `internal/daemon/event.go:toPaneEventPayload` - `internal/persist/` — Atomic workspace/buffer persistence (JSON snapshots, binary ghost buffers) - `internal/pty/` — Cross-platform PTY (build tags: `linux || darwin || freebsd`, `windows`) - `internal/shellinit/` — Automatic OSC 7 + OSC 133 shell integration (embedded init scripts, `//go:embed`) diff --git a/internal/daemon/event.go b/internal/daemon/event.go index 1c479aa..5540a40 100644 --- a/internal/daemon/event.go +++ b/internal/daemon/event.go @@ -152,15 +152,42 @@ func (q *eventQueue) RemoveWatchersByConn(conn *ipc.Conn) { // headroom for legitimate content (multi-line excerpts, command previews, // error stacks) while keeping a runaway event source from bloating the wire. // -// Truncation is from the *front* so the most recent (and usually most -// relevant) content survives — for excerpts that's the last visible line a -// terminal would actually display. +// Truncation strategy: keeps the TAIL because PaneEvent.Message is used for +// terminal excerpts (last visible lines = what the user sees) and idle +// pattern matches (recent output). If a future emitter needs head-truncation +// (e.g. Java-style stack traces where the deepest frame at the top is the +// actual exception), add a PaneEvent.TruncationStrategy field rather than +// special-casing here. +// +// truncationMarker is "…[truncated]" — 14 bytes, NOT 12: the leading "…" is a +// 3-byte UTF-8 ellipsis rune. The slice arithmetic below uses len() so it +// remains correct regardless of marker change, but the cap constants must +// always exceed the marker length. The init-time invariant block below +// enforces this at compile time. const ( - maxEventMessageBytes = 4 * 1024 + maxEventMessageBytes = 4 * 1024 maxEventDataValueBytes = 128 - truncationMarker = "…[truncated]" + truncationMarker = "…[truncated]" ) +// Compile-time invariants. If a future contributor lowers either cap below +// the marker length the conversion of a negative constant to uint will fail +// the build — guaranteeing the slice arithmetic in toPaneEventPayload never +// panics with a negative slice index. +const ( + _ = uint(maxEventMessageBytes - len(truncationMarker) - 1) + _ = uint(maxEventDataValueBytes - len(truncationMarker) - 1) +) + +// truncatedFlagKey is the reserved Data key used to signal that the event +// went through the size-cap path. The `_quil_` prefix establishes a daemon- +// internal namespace so emitters (idle handlers, plugin scrapers, future +// hook events) can never accidentally collide with a meaningful key — +// silently clobbering caller-supplied data would be a confusing failure +// mode. Document any future daemon-internal Data flags under the same +// prefix. +const truncatedFlagKey = "_quil_truncated" + // toPaneEventPayload converts a PaneEvent to an IPC payload, enforcing the // per-event wire-size caps. Caps are applied at the IPC boundary so all // emitters (idle checker, bell, process_exit, future hook events) share the @@ -170,9 +197,6 @@ func toPaneEventPayload(e PaneEvent) ipc.PaneEventPayload { truncated := false if len(message) > maxEventMessageBytes { - // Keep the tail — for excerpts, the bottom-most lines are the - // terminal-visible ones; for error blobs the trailing lines often - // carry the actual cause. message = truncationMarker + message[len(message)-(maxEventMessageBytes-len(truncationMarker)):] truncated = true } @@ -193,7 +217,7 @@ func toPaneEventPayload(e PaneEvent) ipc.PaneEventPayload { if data == nil { data = make(map[string]string, 1) } - data["truncated"] = "1" + data[truncatedFlagKey] = "1" } return ipc.PaneEventPayload{ diff --git a/internal/daemon/event_sizecap_test.go b/internal/daemon/event_sizecap_test.go index 1e500ec..c969776 100644 --- a/internal/daemon/event_sizecap_test.go +++ b/internal/daemon/event_sizecap_test.go @@ -10,8 +10,9 @@ import ( // prevention contract: any single event Message larger than the cap is // truncated from the front, the trailing visible content is preserved, the // payload size lands under the cap, and the Data map carries a "truncated" -// flag for consumers that want to know. +// flag (under the reserved _quil_ namespace) for consumers that want to know. func TestToPaneEventPayload_MessageOverCapTruncatesAndMarks(t *testing.T) { + t.Parallel() // 8 KiB Message — double the cap. original := strings.Repeat("x", 4*1024) + "TAIL_VISIBLE" ev := PaneEvent{ @@ -33,8 +34,8 @@ func TestToPaneEventPayload_MessageOverCapTruncatesAndMarks(t *testing.T) { if !strings.HasSuffix(got.Message, "TAIL_VISIBLE") { t.Errorf("Message should preserve the tail (most recent content) after truncation; got suffix %q", got.Message[len(got.Message)-20:]) } - if got.Data["truncated"] != "1" { - t.Errorf("Data[\"truncated\"] = %q, want \"1\"", got.Data["truncated"]) + if got.Data[truncatedFlagKey] != "1" { + t.Errorf("Data[%q] = %q, want \"1\"", truncatedFlagKey, got.Data[truncatedFlagKey]) } } @@ -42,6 +43,7 @@ func TestToPaneEventPayload_MessageOverCapTruncatesAndMarks(t *testing.T) { // catches misbehaving sources that stuff long strings into Data (e.g. a hook // dumping a full prompt or a stack trace). func TestToPaneEventPayload_DataValueOverCapTruncates(t *testing.T) { + t.Parallel() bigValue := strings.Repeat("y", 1024) ev := PaneEvent{ ID: "evt-1", @@ -60,14 +62,15 @@ func TestToPaneEventPayload_DataValueOverCapTruncates(t *testing.T) { if got.Data["short"] != "ok" { t.Errorf("small Data values must pass through untouched; got %q", got.Data["short"]) } - if got.Data["truncated"] != "1" { - t.Errorf("Data[truncated] = %q, want \"1\"", got.Data["truncated"]) + if got.Data[truncatedFlagKey] != "1" { + t.Errorf("Data[%q] = %q, want \"1\"", truncatedFlagKey, got.Data[truncatedFlagKey]) } } // TestToPaneEventPayload_WithinCapPassesThrough is the happy path — normal // events must not be modified. func TestToPaneEventPayload_WithinCapPassesThrough(t *testing.T) { + t.Parallel() ev := PaneEvent{ ID: "evt-1", PaneID: "pane-1", @@ -85,7 +88,7 @@ func TestToPaneEventPayload_WithinCapPassesThrough(t *testing.T) { if got.Data["exit_code"] != "0" { t.Errorf("Data[exit_code] mutated; got %q", got.Data["exit_code"]) } - if _, ok := got.Data["truncated"]; ok { + if _, ok := got.Data[truncatedFlagKey]; ok { t.Errorf("under-cap event must not carry truncated marker; got Data=%v", got.Data) } } @@ -93,9 +96,102 @@ func TestToPaneEventPayload_WithinCapPassesThrough(t *testing.T) { // TestToPaneEventPayload_NilDataNoTruncationMarker — when nothing is over the // cap, we never allocate a Data map just to set the marker. func TestToPaneEventPayload_NilDataNoTruncationMarker(t *testing.T) { + t.Parallel() ev := PaneEvent{ID: "evt-1", Title: "ok", Message: "short"} got := toPaneEventPayload(ev) if got.Data != nil { t.Errorf("expected nil Data when no truncation needed; got %v", got.Data) } } + +// TestToPaneEventPayload_ExactCapBoundary guards against off-by-one in the +// `> cap` condition. Messages and Data values at *exactly* the cap must pass +// through untouched; one byte over must trigger truncation. A future refactor +// to `>=` (a tempting cleanup) would silently break this. +func TestToPaneEventPayload_ExactCapBoundary(t *testing.T) { + t.Parallel() + + exactMessage := strings.Repeat("m", maxEventMessageBytes) + exactValue := strings.Repeat("v", maxEventDataValueBytes) + ev := PaneEvent{ + ID: "evt-1", + Title: "exact", + Message: exactMessage, + Data: map[string]string{"v": exactValue}, + } + got := toPaneEventPayload(ev) + + if got.Message != exactMessage { + t.Errorf("exact-cap Message must pass through; got len=%d, want %d", len(got.Message), len(exactMessage)) + } + if got.Data["v"] != exactValue { + t.Errorf("exact-cap Data value must pass through; got len=%d, want %d", len(got.Data["v"]), len(exactValue)) + } + if _, ok := got.Data[truncatedFlagKey]; ok { + t.Errorf("exact-cap event must not carry truncated marker; got Data=%v", got.Data) + } + + // One byte over → truncates. + overMessage := exactMessage + "X" + overValue := exactValue + "X" + ev2 := PaneEvent{ + ID: "evt-2", + Title: "over", + Message: overMessage, + Data: map[string]string{"v": overValue}, + } + got2 := toPaneEventPayload(ev2) + if !strings.HasPrefix(got2.Message, truncationMarker) { + t.Errorf("len(cap)+1 Message must trigger truncation; got first 32 chars %q", got2.Message[:32]) + } + if !strings.HasSuffix(got2.Data["v"], truncationMarker) { + t.Errorf("len(cap)+1 Data value must trigger truncation; got %q", got2.Data["v"]) + } +} + +// TestToPaneEventPayload_BothOverCapSetsFlagOnce — when Message AND a Data +// value are both over cap, the truncated flag is set exactly once (the map +// write is idempotent) and both fields are truncated. +func TestToPaneEventPayload_BothOverCapSetsFlagOnce(t *testing.T) { + t.Parallel() + ev := PaneEvent{ + ID: "evt-1", + Title: "both", + Message: strings.Repeat("m", maxEventMessageBytes+10), + Data: map[string]string{"args": strings.Repeat("v", maxEventDataValueBytes+10)}, + } + got := toPaneEventPayload(ev) + + if !strings.HasPrefix(got.Message, truncationMarker) { + t.Errorf("Message must be truncated; got first 32 chars %q", got.Message[:32]) + } + if !strings.HasSuffix(got.Data["args"], truncationMarker) { + t.Errorf("Data[args] must be truncated; got %q", got.Data["args"]) + } + if got.Data[truncatedFlagKey] != "1" { + t.Errorf("flag must be set; got Data[%q] = %q", truncatedFlagKey, got.Data[truncatedFlagKey]) + } +} + +// TestToPaneEventPayload_ReservedKeyDoesNotClobberCaller — emitters can use +// their own keys without fear of collision; the reserved _quil_ prefix +// guarantees daemon-internal flags never overwrite caller data. +func TestToPaneEventPayload_ReservedKeyDoesNotClobberCaller(t *testing.T) { + t.Parallel() + ev := PaneEvent{ + ID: "evt-1", + Title: "with truncated user key", + Data: map[string]string{ + "truncated": "user-supplied-not-clobbered", + "args": strings.Repeat("v", maxEventDataValueBytes+10), // triggers cap + }, + } + got := toPaneEventPayload(ev) + + if got.Data["truncated"] != "user-supplied-not-clobbered" { + t.Errorf("caller's \"truncated\" key must survive; got %q", got.Data["truncated"]) + } + if got.Data[truncatedFlagKey] != "1" { + t.Errorf("daemon flag must be set under reserved key; got %q", got.Data[truncatedFlagKey]) + } +} diff --git a/internal/ipc/broadcast_resilience_test.go b/internal/ipc/broadcast_resilience_test.go index 1e25b4c..1cce3a7 100644 --- a/internal/ipc/broadcast_resilience_test.go +++ b/internal/ipc/broadcast_resilience_test.go @@ -9,11 +9,30 @@ import ( "github.com/artyomsv/quil/internal/ipc" ) +// waitForConnCount polls the server until it reaches the expected client count +// or the deadline elapses. Replaces fragile time.Sleep-after-connect patterns +// that race the daemon's accept goroutine and can silently lose connections +// under CI load. +func waitForConnCount(t *testing.T, srv *ipc.Server, want int, dl time.Duration) { + t.Helper() + deadline := time.Now().Add(dl) + for { + if srv.ConnCount() == want { + return + } + if time.Now().After(deadline) { + t.Fatalf("waitForConnCount: got %d, want %d within %v", srv.ConnCount(), want, dl) + } + time.Sleep(5 * time.Millisecond) + } +} + // TestBroadcast_SlowConnDoesNotBlockFastConn proves the wedge defense: when // one client stops reading from its socket, the daemon's broadcast loop must // continue serving the healthy clients without delay. The Bubble Tea event // loop on a connected TUI cannot be allowed to stall the entire daemon. func TestBroadcast_SlowConnDoesNotBlockFastConn(t *testing.T) { + t.Parallel() sockPath := filepath.Join(t.TempDir(), "slow-vs-fast.sock") srv := ipc.NewServer(sockPath, func(*ipc.Conn, *ipc.Message) {}, nil) @@ -34,8 +53,7 @@ func TestBroadcast_SlowConnDoesNotBlockFastConn(t *testing.T) { } defer slow.Close() - // Wait for the server to register both connections. - time.Sleep(100 * time.Millisecond) + waitForConnCount(t, srv, 2, 2*time.Second) // Slow client deliberately never reads — its kernel socket buffer fills up, // then the daemon's 64-slot per-conn queue overflows, then the daemon @@ -44,8 +62,6 @@ func TestBroadcast_SlowConnDoesNotBlockFastConn(t *testing.T) { const broadcasts = 200 const fastReceives = 50 - // Drain fast client in the background to a counter, so we can assert it - // got messages quickly without blocking. gotFast := make(chan int, broadcasts) go func() { count := 0 @@ -97,10 +113,10 @@ func TestBroadcast_SlowConnDoesNotBlockFastConn(t *testing.T) { // TestBroadcast_ContinuesAfterSlowConnDisconnects covers the post-overflow // state: after the slow conn is torn down, broadcasts to remaining conns -// continue normally. Uses small payloads + a 1 ms pacing gap so the fast -// client's drain goroutine reliably keeps up even under race-detector -// slowdown. +// continue normally. Uses ConnCount-based synchronization (no time.Sleep) so +// CI load doesn't race the connect-registration window. func TestBroadcast_ContinuesAfterSlowConnDisconnects(t *testing.T) { + t.Parallel() sockPath := filepath.Join(t.TempDir(), "post-overflow.sock") srv := ipc.NewServer(sockPath, func(*ipc.Conn, *ipc.Message) {}, nil) @@ -120,7 +136,7 @@ func TestBroadcast_ContinuesAfterSlowConnDisconnects(t *testing.T) { t.Fatalf("slow client: %v", err) } - time.Sleep(100 * time.Millisecond) + waitForConnCount(t, srv, 2, 2*time.Second) var fastCount int var fastMu sync.Mutex @@ -135,19 +151,25 @@ func TestBroadcast_ContinuesAfterSlowConnDisconnects(t *testing.T) { } }() - // Paced burst: enough to overflow the non-reading slow conn but slow - // enough that the fast drain goroutine never falls behind. - smallPayload := map[string]string{"kind": "tick"} + // Paced burst with 4 KiB payloads so the slow client's kernel socket + // buffer (~200 KiB on Linux/Darwin) actually fills — small payloads + // would just sit in the buffer and never trigger overflow. ~150 frames + // × 4 KiB = ~600 KiB, well past the kernel buffer + the 64-slot send + // queue, so overflow trips deterministically. 1 ms pacing keeps the + // fast drain goroutine ahead. + bigPayload := map[string]string{"data": string(make([]byte, 4000))} for i := 0; i < 150; i++ { - msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, smallPayload) + msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, bigPayload) srv.Broadcast(msg) time.Sleep(time.Millisecond) } - // Let the overflow tear down the slow conn. - time.Sleep(200 * time.Millisecond) + // Wait for the slow conn to be torn down via the overflow path. Polling + // on ConnCount converges as soon as the daemon's removeConn fires — + // independent of CI load. + waitForConnCount(t, srv, 1, 3*time.Second) + slow.Close() - time.Sleep(100 * time.Millisecond) fastMu.Lock() pre := fastCount @@ -157,18 +179,79 @@ func TestBroadcast_ContinuesAfterSlowConnDisconnects(t *testing.T) { // see them — the absence of slow in the broadcast fan-out is the // post-overflow invariant we care about. for i := 0; i < 50; i++ { - msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, smallPayload) + msg, _ := ipc.NewMessage(ipc.MsgStateUpdate, bigPayload) srv.Broadcast(msg) time.Sleep(time.Millisecond) } - time.Sleep(300 * time.Millisecond) + // Wait for fast to drain the new wave. With a 1 ms inter-broadcast gap + // and a receive loop that takes microseconds, a 500 ms ceiling is + // generous even under race instrumentation. + deadline := time.Now().Add(500 * time.Millisecond) + for { + fastMu.Lock() + post := fastCount + fastMu.Unlock() + if post-pre >= 30 { + return + } + if time.Now().After(deadline) { + t.Errorf("after slow conn disconnect, fast client only got %d new messages (want ≥ 30)", post-pre) + return + } + time.Sleep(10 * time.Millisecond) + } +} - fastMu.Lock() - post := fastCount - fastMu.Unlock() +// TestBroadcast_MarshalErrorLogsAndReturns covers the otherwise-untested +// failure path where the message payload can't be JSON-encoded. Broadcast +// must NOT panic and must NOT propagate the bad frame to any conn. +func TestBroadcast_MarshalErrorLogsAndReturns(t *testing.T) { + t.Parallel() + sockPath := filepath.Join(t.TempDir(), "bad-marshal.sock") - if post-pre < 30 { - t.Errorf("after slow conn disconnect, fast client only got %d new messages (want ≥ 30)", post-pre) + srv := ipc.NewServer(sockPath, func(*ipc.Conn, *ipc.Message) {}, nil) + if err := srv.Start(); err != nil { + t.Fatalf("server start: %v", err) + } + defer srv.Stop() + + client, err := ipc.NewClient(sockPath) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer client.Close() + + waitForConnCount(t, srv, 1, 2*time.Second) + + // Construct a Message whose Payload is already marshal-valid (json.RawMessage + // of itself is fine) but whose outer Message.Type ends up triggering an + // error path through any future Marshal customization. The realistic + // trigger today: an unencodable payload. We bypass NewMessage and inject + // invalid JSON into the Payload field directly, so json.Marshal of the + // Message tries to re-marshal the bad RawMessage and fails. + bad := &ipc.Message{ + Type: "bad", + Payload: []byte("{not valid json"), // truncated JSON object + } + + // json.Marshal on the Message would fail on the RawMessage's MarshalJSON + // validator. Broadcast should swallow the error and return. + srv.Broadcast(bad) + + // Verify the server is still functional — broadcast a good message and + // the client receives it. + good, _ := ipc.NewMessage(ipc.MsgStateUpdate, map[string]string{"ok": "yes"}) + srv.Broadcast(good) + + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + got, err := client.Receive() + if err != nil { + t.Fatalf("client receive after bad broadcast: %v", err) + } + if got.Type != ipc.MsgStateUpdate { + t.Errorf("expected MsgStateUpdate, got %q", got.Type) } } diff --git a/internal/ipc/conn_internal_test.go b/internal/ipc/conn_internal_test.go new file mode 100644 index 0000000..7d3cdc8 --- /dev/null +++ b/internal/ipc/conn_internal_test.go @@ -0,0 +1,123 @@ +package ipc + +import ( + "net" + "runtime" + "testing" + "time" +) + +// TestSendLoop_ExitsOnWriteError verifies that when the underlying socket +// Write fails (peer disconnected mid-send, kernel detected RST, etc.), +// sendLoop terminates cleanly without leaking the goroutine. The CR finding +// flagged this as the only fully-untested cleanup path in the broadcast +// hardening. +// +// Uses net.Pipe so we can deterministically force a write error by closing +// the remote end mid-send. Goroutine leak is detected via a runtime-level +// goroutine count delta around the conn lifecycle. +func TestSendLoop_ExitsOnWriteError(t *testing.T) { + t.Parallel() + + // Settle the runtime so the baseline goroutine count is stable. + runtime.GC() + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + local, remote := net.Pipe() + c := newConn(local) + + // Close the remote half BEFORE Send. Any subsequent Write on `local` + // returns io.ErrClosedPipe, exercising the sendLoop write-error exit. + remote.Close() + + frame := []byte{0, 0, 0, 1, byte('x')} + if err := c.sendFrame(frame); err != nil { + t.Fatalf("sendFrame should queue even before the write fails; got %v", err) + } + + // Give sendLoop a moment to consume the frame, attempt the write, and + // exit on the resulting error. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + // sendLoop exits when raw.Write errors. After that, our explicit + // Close completes cleanly via sync.Once. Check the closed flag — + // it's set by Close, so we need to call Close to confirm cleanup. + if c.overflow.Load() { + break // not the path under test; bail + } + time.Sleep(10 * time.Millisecond) + } + + // Explicitly close to release the local pipe half. After Close + a brief + // settle, the goroutine count should match baseline (the sendLoop has + // already exited on the write error, and nothing else lingers). + _ = c.Close() + + runtime.GC() + time.Sleep(100 * time.Millisecond) + + if got := runtime.NumGoroutine(); got > baseline+1 { + // +1 tolerates the test runner's own bookkeeping goroutine churn. + t.Errorf("goroutine leak after sendLoop write-error exit: baseline=%d, after=%d", baseline, got) + } +} + +// TestConn_CloseIdempotent confirms sync.Once-guarded Close — multiple +// concurrent close calls from any goroutine (handleConn's defer + overflow's +// async close + Server.Stop's iteration) all funnel through one underlying +// raw.Close. +func TestConn_CloseIdempotent(t *testing.T) { + t.Parallel() + + local, remote := net.Pipe() + defer remote.Close() + + c := newConn(local) + + // Hammer Close from multiple goroutines simultaneously. None should + // panic; the underlying close should run exactly once. We do not assert + // the err returned because only the *first* Close gets the real error; + // the others get nil from the sync.Once.Do default. + const N = 16 + done := make(chan struct{}, N) + for i := 0; i < N; i++ { + go func() { + _ = c.Close() + done <- struct{}{} + }() + } + for i := 0; i < N; i++ { + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("concurrent Close stalled — sync.Once not protecting close path") + } + } + + if !c.closed.Load() { + t.Errorf("Close should have set closed flag") + } +} + +// TestConn_SendFrameAfterCloseShortCircuits confirms the closed-flag short- +// circuit prevents work after Close. Belt-and-suspenders alongside the +// channel-send (which would also fail because sendLoop exited). +func TestConn_SendFrameAfterCloseShortCircuits(t *testing.T) { + t.Parallel() + + local, remote := net.Pipe() + defer remote.Close() + + c := newConn(local) + _ = c.Close() + + if err := c.sendFrame([]byte("x")); err != ErrSendOverflow { + t.Errorf("sendFrame after Close: got %v, want ErrSendOverflow", err) + } + + msg, _ := NewMessage(MsgStateUpdate, map[string]string{"x": "y"}) + if err := c.Send(msg); err != ErrSendOverflow { + t.Errorf("Send after Close: got %v, want ErrSendOverflow", err) + } +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 91dd288..d514ab2 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -3,11 +3,14 @@ package ipc import ( "bytes" "errors" - "log" "net" "os" + "slices" "sync" "sync/atomic" + "time" + + "github.com/artyomsv/quil/internal/logger" ) // sendBufSize is the per-connection queue depth. A wedged or slow client can @@ -20,6 +23,14 @@ import ( // enough that an overflowed conn is detected within a few milliseconds. const sendBufSize = 64 +// writeDeadline bounds how long a single raw.Write may block inside sendLoop +// before we give up on the peer. Belt-and-suspenders alongside the sendCh +// overflow detection: under a wedged kernel buffer + a peer that doesn't +// error on TCP RST, the overflow path is still triggered (sendCh fills → +// next sendFrame trips overflow), but the deadline guarantees a deterministic +// cleanup ceiling instead of an indefinite block. +const writeDeadline = 30 * time.Second + // ErrSendOverflow is returned by Conn.Send when the per-conn send buffer is // full. The connection has been scheduled for close; future Sends short- // circuit with the same error. @@ -59,6 +70,10 @@ func newConn(raw net.Conn) *Conn { // Send marshals msg into the wire frame and queues it for transmission. Returns // ErrSendOverflow when the per-conn buffer is full — the conn has been // scheduled for async close at that point. +// +// The closed/overflow short-circuit here is the fast path: it skips the JSON +// marshal entirely for a known-dead conn. The actual race-safe check happens +// inside sendFrame next to the channel send — do not remove either one. func (c *Conn) Send(msg *Message) error { if c.closed.Load() || c.overflow.Load() { return ErrSendOverflow @@ -67,12 +82,23 @@ func (c *Conn) Send(msg *Message) error { if err := WriteMessage(&buf, msg); err != nil { return err } + // Per-Conn ownership: buf.Bytes() backs a stack-local Buffer whose + // lifetime ends when Send returns, but the channel reference keeps the + // backing array alive until sendLoop's Write completes. No defensive + // copy needed here — only Broadcast (which fans the same frame across + // N conns) clones to decouple the shared slice from its source. return c.sendFrame(buf.Bytes()) } // sendFrame queues a pre-encoded wire frame. Used by Broadcast to share one // marshal allocation across N conns. The frame []byte is read-only — both // sendFrame and sendLoop only read it, never mutate it. +// +// The closed/overflow check here is the race-safe gate that sits next to the +// channel send — necessary because Send's outer check is only a fast-path +// optimization (avoids JSON marshal). A future "cleanup" that drops either +// check would either reintroduce the marshal cost for dead conns or open a +// race where overflow flips between check and send. func (c *Conn) sendFrame(frame []byte) error { if c.closed.Load() || c.overflow.Load() { return ErrSendOverflow @@ -81,11 +107,16 @@ func (c *Conn) sendFrame(frame []byte) error { case c.sendCh <- frame: return nil default: - // Buffer full — slow client. Tear it down asynchronously so the - // broadcaster never blocks on the close, and short-circuit all - // future Sends. - c.overflow.Store(true) - go c.Close() + // Buffer full — slow client. CAS the overflow flag so only the + // first concurrent overflow spawns the Close goroutine and emits + // the log line; all subsequent failed sends short-circuit silently. + // Without the CAS, a wedged peer would log once per broadcast and + // spawn N redundant Close goroutines (each no-ops via closeOnce + // but still pays goroutine spawn cost). + if c.overflow.CompareAndSwap(false, true) { + logger.Warn("ipc: dropping slow client (send buffer overflow)") + go c.Close() + } return ErrSendOverflow } } @@ -96,9 +127,13 @@ func (c *Conn) sendLoop() { case <-c.done: return case frame := <-c.sendCh: + // Bound the per-frame Write to writeDeadline so a peer with a + // wedged kernel buffer or stalled connection cannot block this + // goroutine indefinitely. Deadline errors are reported the same + // way as any other Write failure — sendLoop exits, the read + // side detects the matching error and runs handleConn's defer. + _ = c.raw.SetWriteDeadline(time.Now().Add(writeDeadline)) if _, err := c.raw.Write(frame); err != nil { - // Peer gone or socket error — exit. The read side will see - // the matching error and clean up via handleConn's defer. return } } @@ -109,6 +144,11 @@ func (c *Conn) Receive() (*Message, error) { return ReadMessage(c.raw) } +// Close shuts down the conn. Idempotent — safe to call concurrently from any +// goroutine. Any frames still queued in sendCh at close time are intentionally +// discarded: by the time Close is called we are either tearing down an +// overflowed (already broken) peer or shutting down the server entirely, and +// in both cases delivery guarantees no longer apply. func (c *Conn) Close() error { var err error c.closeOnce.Do(func() { @@ -153,6 +193,11 @@ func (s *Server) Start() error { return nil } +// Stop closes the listener and all active connections. Frames queued in any +// conn's send buffer at the moment of Stop are discarded — Daemon.Stop's +// shutdown sequence does not rely on a final IPC broadcast reaching clients +// (the final-snapshot durability lives in the on-disk workspace.json path, +// not in the wire). func (s *Server) Stop() error { close(s.done) s.mu.Lock() @@ -163,33 +208,52 @@ func (s *Server) Stop() error { return s.listener.Close() } +// ConnCount returns the number of currently-connected clients. Test-friendly +// alternative to the existing log-line scraping pattern; used to wait for +// connect/disconnect events without time-based sleeps. +func (s *Server) ConnCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.conns) +} + // Broadcast sends a message to all connected clients without blocking on any // individual conn. Marshals the wire frame once and shares the bytes across // all per-conn send queues. A slow or wedged conn is dropped from the fan-out -// (logged once) without affecting the others. +// (logged once, per CAS-guarded sendFrame) without affecting the others. func (s *Server) Broadcast(msg *Message) { var buf bytes.Buffer if err := WriteMessage(&buf, msg); err != nil { - log.Printf("broadcast marshal: %v", err) + logger.Error("ipc: broadcast marshal: %v", err) return } - frame := buf.Bytes() + // IMPORTANT: clone the bytes BEFORE fan-out. The slice returned by + // buf.Bytes() aliases a stack-local Buffer whose backing array would + // today survive via channel references — but if a future contributor + // pools the Buffer (sync.Pool, freelist), reuse would silently corrupt + // frames still being read by per-conn sendLoops. The clone decouples + // the shared frame from its source so the contract holds across any + // future Buffer reuse strategy. + // TODO(perf): if broadcast rate ever dominates daemon CPU, consider a + // sync.Pool of [][]byte AND remove this clone — but then every callsite + // that aliases the frame must be re-audited. + frame := slices.Clone(buf.Bytes()) - // Snapshot the conns list under the lock so the per-conn sendFrame calls - // below run lock-free — no risk of a slow send chain interleaving with - // accept/disconnect bookkeeping. + // IMPORTANT: do not remove the slice copy below. The `conns` snapshot + // must be independent of s.conns so the lock-free fan-out cannot race + // with accept/removeConn mutations. Reusing s.conns directly here would + // reintroduce the slow-conn-blocks-everyone bug this whole rewrite fixed. s.mu.Lock() conns := make([]*Conn, len(s.conns)) copy(conns, s.conns) s.mu.Unlock() for _, c := range conns { - if err := c.sendFrame(frame); err != nil { - if errors.Is(err, ErrSendOverflow) { - log.Printf("ipc: dropping slow client (send buffer overflow)") - } else { - log.Printf("broadcast send: %v", err) - } + if err := c.sendFrame(frame); err != nil && !errors.Is(err, ErrSendOverflow) { + // ErrSendOverflow is already logged at the overflow site (CAS + // guarantees exactly one log per conn). Any other error is + // genuinely unexpected. + logger.Error("ipc: broadcast send: %v", err) } } } @@ -202,7 +266,7 @@ func (s *Server) acceptLoop() { case <-s.done: return default: - log.Printf("accept error: %v", err) + logger.Error("ipc: accept error: %v", err) continue } } @@ -213,7 +277,7 @@ func (s *Server) acceptLoop() { count := len(s.conns) s.mu.Unlock() - log.Printf("ipc: client connected (total=%d)", count) + logger.Info("ipc: client connected (total=%d)", count) go s.handleConn(conn) } } @@ -225,7 +289,7 @@ func (s *Server) handleConn(conn *Conn) { s.mu.Lock() count := len(s.conns) s.mu.Unlock() - log.Printf("ipc: client disconnected (remaining=%d)", count) + logger.Info("ipc: client disconnected (remaining=%d)", count) if s.onDisconnect != nil { s.onDisconnect(conn) } From af85f36231ec682dc64759e15019fcfa7c25b0ec Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Fri, 5 Jun 2026 19:11:08 +0200 Subject: [PATCH 03/13] feat(notifications): excerpts, mute, MCP dismiss + since_timestamp Make the notification sidebar genuinely informative and close gaps in the MCP API. Notification events now carry the last few stripped output lines that triggered them, so the sidebar can render context (4-line cards: separator + name/time + title + excerpt preview) and MCP agents no longer need a follow-up read_pane_output per event. Per-pane mute (Alt+M, persisted in workspace.json) drops events at the source for chatty processes like `npm test --watch`. Active-pane output_idle events are suppressed TUI-side as redundant. The MCP surface gains dismiss_notifications (ack from the agent side) and watch_notifications since_timestamp (closes the race between kicking off a task and registering the watcher). Defaults bumped to 200 max events. Dead notification_handlers code path now emits a deprecation warning at plugin load instead of failing silently; docs corrected. --- .claude/CLAUDE.md | 6 +- CHANGELOG.md | 17 ++ cmd/quil/mcp.go | 3 +- cmd/quil/mcp_tools.go | 60 ++++++- docs/keybindings.md | 1 + docs/mcp.md | 13 +- internal/config/config.go | 11 +- internal/daemon/daemon.go | 150 +++++++++++++++--- internal/daemon/event.go | 24 +++ internal/daemon/event_excerpt_test.go | 127 +++++++++++++++ internal/daemon/event_findsince_test.go | 74 +++++++++ internal/daemon/event_mute_test.go | 100 ++++++++++++ internal/daemon/session.go | 7 +- internal/ipc/protocol.go | 12 ++ internal/plugin/registry.go | 11 ++ internal/tui/model.go | 64 +++++++- internal/tui/notification.go | 34 +++- internal/tui/notification_active_pane_test.go | 107 +++++++++++++ internal/tui/notification_excerpt_test.go | 97 +++++++++++ internal/tui/pane.go | 14 +- 20 files changed, 883 insertions(+), 49 deletions(-) create mode 100644 internal/daemon/event_excerpt_test.go create mode 100644 internal/daemon/event_findsince_test.go create mode 100644 internal/daemon/event_mute_test.go create mode 100644 internal/tui/notification_active_pane_test.go create mode 100644 internal/tui/notification_excerpt_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1563e1d..d9f10c3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -178,7 +178,7 @@ Project docs are now organized as a navigable tree under `docs/` (with the index - `docs/features.md` — Feature catalog grouped by area - `docs/keybindings.md` — Full keymap + customization syntax - `docs/configuration.md` — `~/.quil/config.toml` reference -- `docs/mcp.md` — User-facing MCP guide (client wiring, all 17 tools, redaction model) +- `docs/mcp.md` — User-facing MCP guide (client wiring, all 18 tools, redaction model) - `docs/plugin-reference.md` — TOML plugin schema (every field, every strategy, examples) - `docs/troubleshooting.md` — Daemon won't start, MCP not detected, log file locations, reset - `docs/architecture.md` — 24 ADRs (moved from root `ARCHITECTURE.md`) @@ -200,6 +200,6 @@ Project docs are now organized as a navigable tree under `docs/` (with the index - **M6 (Done):** Pane Focus — Ctrl+E toggles active pane full-screen (`TabModel.focusMode`). Layout tree stays intact; `Resize()`/`View()` skip non-active panes. `* FOCUS *` in pane top border, `[focus]` in status bar. Pane nav disabled in focus. Split/close auto-exit focus. Not persisted - **M7 (Done):** Pane Notes — `Alt+E` opens a plain-text editor alongside the active pane (split ~60/40). Notes stored one file per pane at `~/.quil/notes/.md` via `internal/persist/notes.go` (atomic temp+rename). Three save safety nets: 30s debounce via `notesTick`, `Ctrl+S` explicit save, flush on exit. Read-only pane while editing (all keys route to `NotesEditor`). Mutually exclusive with focus mode. Reuses `TextEditor` with new `Highlight string` field (`"plain"` bypasses TOML colouring). Notes survive pane destruction — orphans kept for Phase 2 browser - **M8 (Done):** Bubble Tea v2 + Lipgloss v2 migration — declarative View, typed mouse events, platform-native clipboard (Win32/pbcopy/xclip), text selection (keyboard + mouse), bracketed paste -- **M10 (Done):** MCP Server — `quil mcp` exposes 17 tools via Model Context Protocol (15 original + `get_notifications`/`watch_notifications` from M12 + `get_memory_report`/`get_pane_memory` from M13). Phase A: list_panes, read_pane_output, send_to_pane, get_pane_status, create_pane. Phase B: send_keys, restart_pane, screenshot_pane (VT-emulated), switch_tab, list_tabs, destroy_pane, set_active_pane, close_tui. Official Go SDK (`modelcontextprotocol/go-sdk`). Request-response IPC via `Message.ID` field. TUI cooperation via broadcast messages for set_active_pane and close_tui -- **M12 (Done):** Notification Center — daemon event queue (`internal/daemon/event.go`) with process exit detection and output pattern matching via `[[notification_handlers]]` TOML. TUI sidebar (`internal/tui/notification.go`) toggled via Alt+N, non-modal, coexists with panes. Pane history stack with Alt+Backspace navigation. Status bar badge `[N events]`. MCP tools: `get_notifications` (non-blocking) and `watch_notifications` (blocking, replaces polling). `requestWithTimeout` for long waits up to 5 min +- **M10 (Done):** MCP Server — `quil mcp` exposes 18 tools via Model Context Protocol (15 original + `get_notifications`/`watch_notifications`/`dismiss_notifications` from M12 + `get_memory_report`/`get_pane_memory` from M13). Phase A: list_panes, read_pane_output, send_to_pane, get_pane_status, create_pane. Phase B: send_keys, restart_pane, screenshot_pane (VT-emulated), switch_tab, list_tabs, destroy_pane, set_active_pane, close_tui. Official Go SDK (`modelcontextprotocol/go-sdk`). Request-response IPC via `Message.ID` field. TUI cooperation via broadcast messages for set_active_pane and close_tui +- **M12 (Done):** Notification Center — daemon event queue (`internal/daemon/event.go`) with process exit detection and pattern matching via `[[idle_handlers]]` (legacy `[[notification_handlers]]` is parsed but never evaluated — a one-shot deprecation warning fires on plugin load). Events now carry the last few stripped output lines as `Message` + `Data["excerpt"]`, populated by `paneOutputExcerpt(pane, n)` + `withExcerpt(event, excerpt)` at every emit site (idle/bell/OSC133/process_exit). Per-pane mute (`Pane.Muted`, persisted via workspace JSON, `Alt+M` toggles for the active pane) drops events at the source so chatty processes don't flood the sidebar. TUI sidebar (`internal/tui/notification.go`) toggled via Alt+N, non-modal, coexists with panes; each event card now renders excerpt under title (4 lines per event, padded for stable pagination). Active-pane `output_idle` events are suppressed TUI-side as redundant (`Model.isActivePane`). Pane history stack with Alt+Backspace navigation. Status bar badge `[N events]`. MCP tools: `get_notifications` (non-blocking, returns full Data including excerpt), `watch_notifications` (blocking, accepts `since_timestamp` to close the race between agent action and watcher registration), and `dismiss_notifications` (ack events from the agent side). Default `MaxEvents` raised to 200. `requestWithTimeout` for long waits up to 5 min - **M13 (Done):** Memory reporting — daemon-side 5s collector (`internal/memreport/`) snapshots per-pane Go-heap (OutputBuf + GhostSnap + plugin state) and PTY child RSS via platform files (`/proc//status` on Linux, `ps -o rss=` batched on Darwin, `GetProcessMemoryInfo` on Windows, no-op stub elsewhere). New IPC pair `MsgMemoryReportReq`/`MsgMemoryReportResp`. TUI dialog (`dialogMemory`) with tab→pane tree and expand/collapse (F1 → Memory), status-bar `mem ` segment refreshed every 5s, and per-pane notes-editor byte accounting. Two MCP tools: `get_memory_report` (per-tab totals) and `get_pane_memory` (single pane detail). VT grid TUI memory explicitly deferred — no stable public emulator accessor. diff --git a/CHANGELOG.md b/CHANGELOG.md index 668bd91..9142510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Notification events carry an excerpt of the triggering output** — every `process_exit`, `command_complete`, `bell`, and `output_idle` event now embeds the last few stripped output lines in the event's `Message` field and `Data["excerpt"]`. The notification sidebar renders the first line of the excerpt as a 4th line per event card (dim grey, blank when there is none). MCP consumers see the full excerpt in the event payload, so an agent can act on context without a follow-up `read_pane_output` round-trip. Single helper `paneOutputExcerpt(pane, n)` reads the trailing 4 KiB of the ring buffer, ANSI-strips it, and returns the last n non-empty lines; `withExcerpt(event, excerpt)` populates the fields idempotently. +- **Per-pane notification mute** — `Alt+M` toggles a `[muted]` chip on the active pane and suppresses every notification event sourced from that pane (idle, bell, OSC133, process exit). Events are dropped at the daemon, not just hidden in the UI, so muted panes never enter the queue, never wake watchers, and never reach `get_notifications`. Solves the "`npm test --watch` floods the sidebar" problem without disabling notifications globally. Mute is persisted in `workspace.json` (`paneData["muted"] = true`) and survives daemon restart. `MsgUpdatePane` gains an optional `Muted *bool` field (pointer so unset is distinguishable from explicit false). +- **MCP `dismiss_notifications` tool** — agents can finally ack events from their side. Pass `event_id` to dismiss a single event, or omit it to clear the entire queue. Closes a long-standing asymmetry: `get_notifications` was read-only, so MCP-only sessions accumulated events until the bounded queue evicted them. +- **MCP `watch_notifications` `since_timestamp` parameter** — closes the race between "kick off a task" and "start watching." When an agent passes the timestamp of the last event it handled, the daemon scans the existing event queue for the oldest event newer than the marker, returning it immediately without registering a blocking watcher. New `eventQueue.FindSince(sinceMs, paneFilter)` walks the queue oldest-to-newest so agents process events in order. + +### Changed + +- **Default `notification.max_events` raised from 50 to 200** — a busy multi-pane session evicts 50 events within an hour. 200 events at ~300 bytes each is ~60 KB, negligible memory, and gives genuinely useful history depth. +- **Active-pane `output_idle` events are suppressed in the sidebar** — TUI-side filter in the `paneEventMsg` handler. The pane you're staring at is by definition idle when you can see it idling; the sidebar entry is pure noise. Other event types (`process_exit`, `bell`, `command_complete`) still queue on the active pane because they're transient state changes worth a sidebar audit-trail entry. +- **`docs/mcp.md` corrected** — the event-observation section incorrectly referenced `[[notification_handlers]]` as the source of idle matches. The actual mechanism has been `[[idle_handlers]]` since the deprecated `MatchNotification` codepath was removed from the daemon; anyone editing the legacy section was getting silent no-ops. Plugin loader now logs a one-shot deprecation warning per stale plugin. + +### Internal + +- **Defensive nil-guards on `Daemon.broadcastState` and `emitEvent`** — both now no-op when `d.server` is nil, allowing unit tests that exercise notification dispatch and pane updates to construct a bare `Daemon` via `New(config.Default())` without spinning up the IPC server. Production behavior is unchanged — `d.server` is always non-nil after `Start()`. + ## [1.15.1] - 2026-06-05 ### Fixed diff --git a/cmd/quil/mcp.go b/cmd/quil/mcp.go index ef7c0d5..ea18d40 100644 --- a/cmd/quil/mcp.go +++ b/cmd/quil/mcp.go @@ -164,7 +164,8 @@ func runMCP() { "- send_to_pane: for typing text commands — appends newline by default to execute.\n" + "- Destructive tools (restart_pane, destroy_pane, close_tui): always confirm with the user before using.\n" + "- watch_notifications: blocks until an event fires on specified panes (replaces polling). Use after starting long-running tasks.\n" + - "- get_notifications: returns all pending notification events without blocking.\n\n" + + "- get_notifications: returns all pending notification events without blocking.\n" + + "- dismiss_notifications: ack events you've handled so they don't show up again. Pass an event_id, or omit to clear all.\n\n" + "Sensitive data handling:\n" + "When sending sensitive data (passwords, API keys, tokens, seeds) via send_to_pane or send_keys, " + "wrap the value with <>...<> markers.\n" + diff --git a/cmd/quil/mcp_tools.go b/cmd/quil/mcp_tools.go index dd446c0..6842933 100644 --- a/cmd/quil/mcp_tools.go +++ b/cmd/quil/mcp_tools.go @@ -31,6 +31,7 @@ func registerMCPTools(s *mcp.Server, bridge *mcpBridge, mcpLog *mcpLogger) { // Notification tools registerGetNotificationsTool(s, bridge, mcpLog) registerWatchNotificationsTool(s, bridge, mcpLog) + registerDismissNotificationsTool(s, bridge, mcpLog) // Memory reporting registerGetMemoryReportTool(s, bridge, mcpLog) registerGetPaneMemoryTool(s, bridge, mcpLog) @@ -471,16 +472,62 @@ func registerGetNotificationsTool(s *mcp.Server, bridge *mcpBridge, mcpLog *mcpL }) } +// registerDismissNotificationsTool exposes the same dismiss path the TUI uses +// (MsgDismissEvent). The previous MCP surface was read-only — get_notifications +// returned the queue but no agent could drain it, so MCP-only sessions +// accumulated events until the daemon's bounded queue evicted them. Now an +// agent can ack events explicitly: dismiss a single event by ID, or pass an +// empty event_id to clear everything. +func registerDismissNotificationsTool(s *mcp.Server, bridge *mcpBridge, mcpLog *mcpLogger) { + type Input struct { + EventID string `json:"event_id,omitempty" jsonschema:"event id to dismiss (empty = dismiss all)"` + } + + mcp.AddTool(s, &mcp.Tool{ + Name: "dismiss_notifications", + Description: "Dismiss notification events from the daemon's queue. Pass event_id to drop a single event " + + "(matching one returned by get_notifications), or omit it to clear all pending events. Use after " + + "acting on an event so it does not show up again on the next get_notifications call.", + }, func(_ context.Context, _ *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, any, error) { + mcpLog.Log("", "dismiss_notifications", fmt.Sprintf("event_id=%q", input.EventID)) + + msg, err := ipc.NewMessage(ipc.MsgDismissEvent, ipc.DismissEventPayload{ + EventID: input.EventID, + }) + if err != nil { + return nil, nil, fmt.Errorf("dismiss_notifications build: %w", err) + } + // MsgDismissEvent is fire-and-forget on the daemon side — no response + // IPC message exists. sendRaw is thread-safe; raw Send through the + // shared bridge.client could race with the response read loop's + // internal locking. + if err := bridge.sendRaw(msg); err != nil { + return nil, nil, fmt.Errorf("dismiss_notifications send: %w", err) + } + + summary := "Dismissed all notifications." + if input.EventID != "" { + summary = fmt.Sprintf("Dismissed notification %s.", input.EventID) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: summary}}, + }, nil, nil + }) +} + func registerWatchNotificationsTool(s *mcp.Server, bridge *mcpBridge, mcpLog *mcpLogger) { type Input struct { - PaneIDs []string `json:"pane_ids,omitempty" jsonschema:"pane IDs to watch (empty = all panes)"` - Timeout int `json:"timeout,omitempty" jsonschema:"timeout in seconds (default 60, max 300)"` + PaneIDs []string `json:"pane_ids,omitempty" jsonschema:"pane IDs to watch (empty = all panes)"` + Timeout int `json:"timeout,omitempty" jsonschema:"timeout in seconds (default 60, max 300)"` + SinceTimestamp int64 `json:"since_timestamp,omitempty" jsonschema:"if set (Unix ms), return immediately with the oldest queued event newer than this — closes the race between kicking off a task and starting to watch. Pass the timestamp of the last event you handled."` } mcp.AddTool(s, &mcp.Tool{ Name: "watch_notifications", Description: "Block until a notification event fires for the specified panes. Returns event details when a process exits, " + - "output matches a notification pattern, or timeout. Use this instead of polling with sleep + screenshot.", + "output matches a notification pattern, or timeout. Use this instead of polling with sleep + screenshot. " + + "Pass since_timestamp (the Unix ms of the last event you saw) to recover events that may have fired between " + + "your previous action and this call.", }, func(_ context.Context, _ *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, any, error) { timeout := input.Timeout if timeout <= 0 { @@ -490,13 +537,14 @@ func registerWatchNotificationsTool(s *mcp.Server, bridge *mcpBridge, mcpLog *mc timeout = 300 } - mcpLog.Log("", "watch_notifications", fmt.Sprintf("panes=%d timeout=%ds", len(input.PaneIDs), timeout)) + mcpLog.Log("", "watch_notifications", fmt.Sprintf("panes=%d timeout=%ds since=%d", len(input.PaneIDs), timeout, input.SinceTimestamp)) resp, err := bridge.requestWithTimeout( ipc.MsgWatchNotificationsReq, ipc.WatchNotificationsReqPayload{ - PaneIDs: input.PaneIDs, - TimeoutMs: timeout * 1000, + PaneIDs: input.PaneIDs, + TimeoutMs: timeout * 1000, + SinceTimestamp: input.SinceTimestamp, }, time.Duration(timeout+5)*time.Second, ) diff --git a/docs/keybindings.md b/docs/keybindings.md index 4492a9f..91ab577 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -81,6 +81,7 @@ You can bind `next_pane` / `prev_pane` in `config.toml` if you prefer linear cyc |---|---| | `Alt+N` | Cycle sidebar visibility: hidden → visible+unfocused → visible+focused → hidden | | `F3` | Focus the notification sidebar (when visible) | +| `Alt+M` | Mute / unmute notifications for the active pane. Muted panes show `[muted]` on the border and never fire process-exit, bell, OSC 133, or idle events. Useful for `npm test --watch` and other chatty processes. | ## Clipboard diff --git a/docs/mcp.md b/docs/mcp.md index adb8783..f8747f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -14,7 +14,7 @@ The result: your AI can **see what's in your build pane and react**, instead of - [VS Code (GitHub Copilot Chat)](#vs-code-github-copilot-chat) - [Any MCP-capable client](#any-mcp-capable-client) - [Verify the connection](#verify-the-connection) -- [The 17 tools](#the-17-tools) +- [The 18 tools](#the-18-tools) - [Discovery](#discovery) - [Reading pane output](#reading-pane-output) - [Interacting with panes](#interacting-with-panes) @@ -67,7 +67,7 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) o } ``` -Restart Claude Desktop. The 🔌 icon in the input bar should show Quil with 17 tools. +Restart Claude Desktop. The 🔌 icon in the input bar should show Quil with 18 tools. ### Claude Code (CLI) @@ -136,7 +136,7 @@ In your AI client, ask: The AI should call `list_panes` and return a JSON array with each pane's `id`, `type`, `tab_id`, `cwd`, etc. If you see "no Quil panes" or an error, check [Troubleshooting](#troubleshooting). -## The 17 tools +## The 18 tools Tools are grouped below by purpose. Every tool returns a `text` content block; many return JSON-formatted payloads. @@ -194,10 +194,11 @@ Replace polling-with-sleep + screenshot with the blocking watcher. | Tool | Input | Returns | Notes | |---|---|---|---| -| `get_notifications` | — | JSON array of pending events: `{pane_id, kind, title, body, timestamp, severity}` | Non-blocking. Drains the daemon's event queue. | -| `watch_notifications` | `pane_ids` (optional, empty = all), `timeout` (seconds, default 60, max 300) | JSON: the first event that fires, or `{timed_out: true}` | **Blocks** up to `timeout` seconds. Use after kicking off a long-running task ("watch the build pane until it finishes"). Replaces sleep+poll patterns. | +| `get_notifications` | — | JSON array of pending events: `{pane_id, kind, title, body, timestamp, severity, data}` | Non-blocking. Returns the daemon's event queue without removing entries — use `dismiss_notifications` to ack. Each event's `data.excerpt` carries the last lines of pane output that triggered the event. | +| `watch_notifications` | `pane_ids` (optional, empty = all), `timeout` (seconds, default 60, max 300), `since_timestamp` (Unix ms, optional) | JSON: the first event that fires, or `{timed_out: true}` | **Blocks** up to `timeout` seconds. Use after kicking off a long-running task ("watch the build pane until it finishes"). Replaces sleep+poll patterns. Pass `since_timestamp` (the timestamp of the last event you handled) to catch events fired between your previous action and this call — the daemon returns the oldest queued event newer than the marker without ever registering a watcher. | +| `dismiss_notifications` | `event_id` (optional, empty = dismiss all) | Confirmation string | Acks events the agent has already handled so they don't show up again on the next `get_notifications` call. | -The events fired by the daemon include: process exits (any pane), OSC 133 command completion (shell panes), bell characters (with 30 s cooldown to avoid storming), and smart-idle pattern matches based on per-plugin `[[notification_handlers]]` in TOML. +The events fired by the daemon include: process exits (any pane), OSC 133 command completion (shell panes), bell characters (with 30 s cooldown to avoid storming), and smart-idle pattern matches based on per-plugin `[[idle_handlers]]` in TOML. Each event carries a `data.excerpt` field with the last few stripped lines that triggered it, so an agent can act on the context without a follow-up `read_pane_output` call. ### Memory reporting diff --git a/internal/config/config.go b/internal/config/config.go index d9c0be3..30a6292 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,8 +105,12 @@ type KeybindingsConfig struct { FocusPane string `toml:"focus_pane"` NotificationToggle string `toml:"notification_toggle"` NotificationFocus string `toml:"notification_focus"` - GoBack string `toml:"go_back"` - NotesToggle string `toml:"notes_toggle"` + // MutePane toggles notification mute on the active pane (idle/bell/exit + // events stop firing). Useful for `npm test --watch` and other chatty + // processes that would otherwise flood the sidebar. + MutePane string `toml:"mute_pane"` + GoBack string `toml:"go_back"` + NotesToggle string `toml:"notes_toggle"` } func Default() Config { @@ -141,7 +145,7 @@ func Default() Config { }, Notification: NotificationConfig{ SidebarWidth: 30, - MaxEvents: 50, + MaxEvents: 200, }, Keybindings: KeybindingsConfig{ Quit: "ctrl+q", @@ -172,6 +176,7 @@ func Default() Config { FocusPane: "ctrl+e", NotificationToggle: "alt+n", NotificationFocus: "f3", + MutePane: "alt+m", GoBack: "alt+backspace", NotesToggle: "alt+e", }, diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e4b3503..26f1a9e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -452,6 +452,8 @@ func (d *Daemon) restoreWorkspace() error { } } + muted, _ := paneData["muted"].(bool) + pane := &Pane{ ID: paneID, TabID: tabID, @@ -462,6 +464,7 @@ func (d *Daemon) restoreWorkspace() error { InstanceName: instanceName, InstanceArgs: instanceArgs, OutputBuf: ringbuf.NewRingBuffer(d.session.bufSize), + Muted: muted, } // Load ghost buffer from disk @@ -999,7 +1002,14 @@ func (d *Daemon) handleUpdatePane(msg *ipc.Message) { if payload.CWD != "" { pane.CWD = payload.CWD } + if payload.Muted != nil { + pane.PluginMu.Lock() + pane.Muted = *payload.Muted + pane.PluginMu.Unlock() + log.Printf("pane %s: muted=%v", pane.ID, *payload.Muted) + } d.broadcastState() + d.requestSnapshot() } func (d *Daemon) handleReloadPlugins() { @@ -1086,7 +1096,7 @@ func (d *Daemon) streamPTYOutput(paneID string, pty apty.Session) { severity = "error" title = fmt.Sprintf("Process failed (code %d)", code) } - d.emitEvent(PaneEvent{ + d.emitEvent(withExcerpt(PaneEvent{ ID: uuid.New().String(), PaneID: paneID, TabID: pane.TabID, @@ -1096,7 +1106,7 @@ func (d *Daemon) streamPTYOutput(paneID string, pty apty.Session) { Severity: severity, Timestamp: time.Now(), Data: map[string]string{"exit_code": strconv.Itoa(code)}, - }) + }, paneOutputExcerpt(pane, 5))) } return } @@ -1157,11 +1167,11 @@ func (d *Daemon) detectBellEvent(pane *Pane, paneID string, data []byte) { return } pane.LastBellEventAt = time.Now() - d.emitEvent(PaneEvent{ + d.emitEvent(withExcerpt(PaneEvent{ ID: uuid.New().String(), PaneID: paneID, TabID: pane.TabID, PaneName: pane.Name, Type: "bell", Title: "Attention", Severity: "warning", Timestamp: time.Now(), - }) + }, paneOutputExcerpt(pane, 3))) } // detectOSC133Exit parses OSC 133;D (command complete) sequences from shell integration. @@ -1185,12 +1195,12 @@ func (d *Daemon) detectOSC133Exit(pane *Pane, paneID string, data []byte) { severity = "error" title = fmt.Sprintf("Command failed (code %d)", code) } - d.emitEvent(PaneEvent{ + d.emitEvent(withExcerpt(PaneEvent{ ID: uuid.New().String(), PaneID: paneID, TabID: pane.TabID, PaneName: pane.Name, Type: "command_complete", Title: title, Severity: severity, Timestamp: time.Now(), Data: map[string]string{"exit_code": strconv.Itoa(code)}, - }) + }, paneOutputExcerpt(pane, 5))) } // applyPluginHandlers runs scraping, error matching for non-terminal plugins. @@ -1222,6 +1232,9 @@ func (d *Daemon) applyPluginHandlers(pane *Pane, paneID string, data []byte) { } func (d *Daemon) broadcastState() { + if d.server == nil { + return + } state := d.buildWorkspaceState() resp, _ := ipc.NewMessage(ipc.MsgWorkspaceState, state) d.server.Broadcast(resp) @@ -1275,6 +1288,9 @@ func (d *Daemon) workspaceStateFromSnapshot(activeTab string, tabs []*Tab, panes } paneData["plugin_state"] = ps } + if pane.Muted { + paneData["muted"] = true + } pane.PluginMu.Unlock() if pane.InstanceName != "" { paneData["instance_name"] = pane.InstanceName @@ -1728,12 +1744,37 @@ func (d *Daemon) respondToAndHighlight(conn *ipc.Conn, requestID, msgType string d.highlightPane(paneID) } +// findEventSince delegates to the event queue's catch-up scan. Returns the +// oldest queued event newer than sinceUnixMilli matching paneFilter (empty +// filter = any pane), or nil. Used by watch_notifications's race-closing +// short-circuit before a watcher is registered. +func (d *Daemon) findEventSince(sinceUnixMilli int64, paneFilter map[string]bool) *PaneEvent { + return d.events.FindSince(sinceUnixMilli, paneFilter) +} + // emitEvent pushes an event to the queue and broadcasts to all clients. +// Events from muted panes are dropped entirely — neither queued nor broadcast. +// Mute is a per-pane signal-quality control: panes like `npm test --watch` +// fire idle handlers on every iteration, and the only sane treatment is to +// silence them at the source. Process-exit on a muted pane is also silenced — +// once you say "stop telling me about this pane", we honor it. func (d *Daemon) emitEvent(e PaneEvent) { + if e.PaneID != "" { + if pane := d.session.Pane(e.PaneID); pane != nil { + pane.PluginMu.Lock() + muted := pane.Muted + pane.PluginMu.Unlock() + if muted { + return + } + } + } d.events.Push(e) payload := toPaneEventPayload(e) msg, _ := ipc.NewMessage(ipc.MsgPaneEvent, payload) - d.server.Broadcast(msg) + if d.server != nil { + d.server.Broadcast(msg) + } } // idleChecker runs a periodic check for panes that have gone idle. @@ -1773,8 +1814,8 @@ func (d *Daemon) checkIdlePanes() { continue } - title, severity := d.analyzeIdleTitle(pane) - d.emitEvent(PaneEvent{ + title, severity, excerpt := d.analyzeIdleTitle(pane) + d.emitEvent(withExcerpt(PaneEvent{ ID: uuid.New().String(), PaneID: pane.ID, TabID: pane.TabID, @@ -1783,14 +1824,16 @@ func (d *Daemon) checkIdlePanes() { Title: title, Severity: severity, Timestamp: now, - }) + }, excerpt)) } } } // analyzeIdleTitle determines the notification title/severity by matching -// the last few lines of pane output against plugin idle handlers. -func (d *Daemon) analyzeIdleTitle(pane *Pane) (title, severity string) { +// the last few lines of pane output against plugin idle handlers. The +// excerpt is the same text used for regex matching — returned so the caller +// can attach it to the event without a second buffer read. +func (d *Daemon) analyzeIdleTitle(pane *Pane) (title, severity, excerpt string) { title = "Output idle" severity = "info" @@ -1805,18 +1848,24 @@ func (d *Daemon) analyzeIdleTitle(pane *Pane) (title, severity string) { title = "Waiting for input" severity = "warning" } - if pane.OutputBuf != nil && len(p.IdleHandlers) > 0 { - raw := pane.OutputBuf.Bytes() - // Limit to last 4KB for performance - if len(raw) > 4096 { - raw = raw[len(raw)-4096:] - } - stripped := ansi.Strip(string(raw)) - text := lastNLines(stripped, 5) - if ih := plugin.MatchIdle(p, text); ih != nil { - title = ih.Title - severity = ih.Severity - } + if pane.OutputBuf == nil { + return + } + raw := pane.OutputBuf.Bytes() + if len(raw) == 0 { + return + } + if len(raw) > 4096 { + raw = raw[len(raw)-4096:] + } + stripped := ansi.Strip(string(raw)) + excerpt = lastNLines(stripped, 5) + if len(p.IdleHandlers) == 0 || excerpt == "" { + return + } + if ih := plugin.MatchIdle(p, excerpt); ih != nil { + title = ih.Title + severity = ih.Severity } return } @@ -1834,6 +1883,44 @@ func lastNLines(text string, n int) string { return strings.Join(result, "\n") } +// paneOutputExcerpt extracts the last n non-empty stripped lines from a pane's +// ring buffer. Used to enrich notification events with the context that +// triggered them so the sidebar and MCP consumers can show something more +// informative than the title alone. Returns "" if the buffer is empty. +// +// Reads only the trailing 4 KiB of the ring buffer — enough for ~50 wrapped +// lines on a typical terminal, far more than n=3 needs, and bounded so the +// per-event cost stays negligible even for panes with very large buffers. +func paneOutputExcerpt(pane *Pane, n int) string { + if pane == nil || pane.OutputBuf == nil { + return "" + } + raw := pane.OutputBuf.Bytes() + if len(raw) == 0 { + return "" + } + if len(raw) > 4096 { + raw = raw[len(raw)-4096:] + } + return lastNLines(ansi.Strip(string(raw)), n) +} + +// withExcerpt populates PaneEvent.Message and Data["excerpt"] from the pane's +// tail output. Idempotent: callers that already extracted the excerpt (e.g. +// the idle checker, which needs it for regex matching) can pass excerpt +// directly and skip the second buffer read. +func withExcerpt(e PaneEvent, excerpt string) PaneEvent { + if excerpt == "" { + return e + } + e.Message = excerpt + if e.Data == nil { + e.Data = make(map[string]string) + } + e.Data["excerpt"] = excerpt + return e +} + func (d *Daemon) handleListPanesReq(conn *ipc.Conn, msg *ipc.Message) { _, tabs, panesByTab := d.session.SnapshotState() @@ -2296,6 +2383,21 @@ func (d *Daemon) handleWatchNotificationsReq(conn *ipc.Conn, msg *ipc.Message) { paneFilter[id] = true } + // since_timestamp short-circuit: scan the existing queue for any event + // newer than the marker that also matches the pane filter. If one + // exists, return it without ever registering a watcher. This closes the + // race-on-registration window — events fired between the agent's prior + // action and this watch call would otherwise be lost. + if req.SinceTimestamp > 0 { + if catchup := d.findEventSince(req.SinceTimestamp, paneFilter); catchup != nil { + payload := toPaneEventPayload(*catchup) + respondTo(conn, msg.ID, ipc.MsgWatchNotificationsResp, ipc.WatchNotificationsRespPayload{ + Event: &payload, + }) + return + } + } + // Remove any existing watcher for this connection (limit 1 per connection) d.events.RemoveWatchersByConn(conn) diff --git a/internal/daemon/event.go b/internal/daemon/event.go index 5540a40..1b07720 100644 --- a/internal/daemon/event.go +++ b/internal/daemon/event.go @@ -102,6 +102,30 @@ func (q *eventQueue) Events() []PaneEvent { return out } +// FindSince scans the queue for the OLDEST event whose Timestamp is strictly +// greater than sinceUnixMilli AND whose PaneID is in paneFilter (or for any +// pane when paneFilter is empty). Returns a copy of the matching event or +// nil. Iterating oldest-to-newest is deliberate: the caller (an agent) wants +// to process events in order, not jump straight to the latest. The queue is +// stored newest-first, so the scan walks the slice in reverse. +func (q *eventQueue) FindSince(sinceUnixMilli int64, paneFilter map[string]bool) *PaneEvent { + q.mu.Lock() + defer q.mu.Unlock() + + for i := len(q.events) - 1; i >= 0; i-- { + e := q.events[i] + if e.Timestamp.UnixMilli() <= sinceUnixMilli { + continue + } + if len(paneFilter) > 0 && !paneFilter[e.PaneID] { + continue + } + cp := e + return &cp + } + return nil +} + // Count returns the number of pending events. func (q *eventQueue) Count() int { q.mu.Lock() diff --git a/internal/daemon/event_excerpt_test.go b/internal/daemon/event_excerpt_test.go new file mode 100644 index 0000000..7d4cb30 --- /dev/null +++ b/internal/daemon/event_excerpt_test.go @@ -0,0 +1,127 @@ +package daemon + +import ( + "strings" + "testing" + + "github.com/artyomsv/quil/internal/ringbuf" +) + +func TestPaneOutputExcerpt_LastNLines(t *testing.T) { + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} + pane.OutputBuf.Write([]byte("first\nsecond\nthird\nfourth\n")) + + got := paneOutputExcerpt(pane, 2) + want := "third\nfourth" + if got != want { + t.Errorf("paneOutputExcerpt: got %q, want %q", got, want) + } +} + +func TestPaneOutputExcerpt_SkipsEmptyLines(t *testing.T) { + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} + pane.OutputBuf.Write([]byte("alpha\n\n\nbeta\n\n")) + + got := paneOutputExcerpt(pane, 2) + want := "alpha\nbeta" + if got != want { + t.Errorf("paneOutputExcerpt skip empty: got %q, want %q", got, want) + } +} + +func TestPaneOutputExcerpt_StripsANSI(t *testing.T) { + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} + // Red "error" + reset, then a plain line. + pane.OutputBuf.Write([]byte("\x1b[31merror\x1b[0m\nokay\n")) + + got := paneOutputExcerpt(pane, 2) + want := "error\nokay" + if got != want { + t.Errorf("paneOutputExcerpt ANSI strip: got %q, want %q", got, want) + } +} + +func TestPaneOutputExcerpt_EmptyBuffer(t *testing.T) { + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} + if got := paneOutputExcerpt(pane, 3); got != "" { + t.Errorf("paneOutputExcerpt empty: got %q, want %q", got, "") + } +} + +func TestPaneOutputExcerpt_NilBuffer(t *testing.T) { + pane := &Pane{} + if got := paneOutputExcerpt(pane, 3); got != "" { + t.Errorf("paneOutputExcerpt nil buf: got %q, want %q", got, "") + } +} + +func TestPaneOutputExcerpt_NilPane(t *testing.T) { + if got := paneOutputExcerpt(nil, 3); got != "" { + t.Errorf("paneOutputExcerpt nil pane: got %q, want %q", got, "") + } +} + +func TestPaneOutputExcerpt_LargeBufferTrailingCap(t *testing.T) { + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(16384)} + // Build > 4 KiB of "line N\n" — only the trailing window should be read. + var sb strings.Builder + for i := 0; i < 500; i++ { + sb.WriteString("line ") + sb.WriteString("padding-padding-padding-padding\n") + } + pane.OutputBuf.Write([]byte(sb.String())) + + got := paneOutputExcerpt(pane, 1) + // We only care that something was returned and that we did not OOM on a + // large buffer. The exact tail depends on the 4 KiB window boundary. + if got == "" { + t.Errorf("paneOutputExcerpt large buf: got empty") + } +} + +func TestWithExcerpt_PopulatesMessageAndData(t *testing.T) { + e := PaneEvent{ + ID: "evt-1", + Type: "process_exit", + Title: "Process exited (code 0)", + Data: map[string]string{"exit_code": "0"}, + } + got := withExcerpt(e, "last line of output") + if got.Message != "last line of output" { + t.Errorf("Message: got %q, want %q", got.Message, "last line of output") + } + if got.Data["excerpt"] != "last line of output" { + t.Errorf("Data[excerpt]: got %q, want %q", got.Data["excerpt"], "last line of output") + } + if got.Data["exit_code"] != "0" { + t.Errorf("preserved exit_code: got %q, want %q", got.Data["exit_code"], "0") + } +} + +func TestWithExcerpt_EmptyExcerptIsNoop(t *testing.T) { + e := PaneEvent{ + ID: "evt-1", + Data: map[string]string{"exit_code": "0"}, + } + got := withExcerpt(e, "") + if got.Message != "" { + t.Errorf("Message: got %q, want empty", got.Message) + } + if _, ok := got.Data["excerpt"]; ok { + t.Errorf("Data[excerpt] should not be set on empty excerpt") + } + if got.Data["exit_code"] != "0" { + t.Errorf("preserved exit_code: got %q, want %q", got.Data["exit_code"], "0") + } +} + +func TestWithExcerpt_NilData_CreatedOnDemand(t *testing.T) { + e := PaneEvent{ID: "evt-1"} + got := withExcerpt(e, "context") + if got.Data == nil { + t.Fatalf("Data: got nil, want allocated map") + } + if got.Data["excerpt"] != "context" { + t.Errorf("Data[excerpt]: got %q, want %q", got.Data["excerpt"], "context") + } +} diff --git a/internal/daemon/event_findsince_test.go b/internal/daemon/event_findsince_test.go new file mode 100644 index 0000000..9f39a4a --- /dev/null +++ b/internal/daemon/event_findsince_test.go @@ -0,0 +1,74 @@ +package daemon + +import ( + "testing" + "time" +) + +// TestEventQueue_FindSince_ReturnsOldestNewerEvent demonstrates the contract +// that watch_notifications uses: when an agent passes since_timestamp, the +// daemon walks the queue oldest-to-newest and returns the first event whose +// timestamp is strictly greater than the marker. Oldest-first matters +// because agents handle events in order — jumping straight to the newest +// would skip intermediate state changes. +func TestEventQueue_FindSince_ReturnsOldestNewerEvent(t *testing.T) { + q := newEventQueue(10) + t0 := time.Unix(0, 0).Add(1000 * time.Millisecond) + q.Push(PaneEvent{ID: "old", PaneID: "p1", Timestamp: t0}) + q.Push(PaneEvent{ID: "mid", PaneID: "p1", Timestamp: t0.Add(50 * time.Millisecond)}) + q.Push(PaneEvent{ID: "new", PaneID: "p1", Timestamp: t0.Add(100 * time.Millisecond)}) + + got := q.FindSince(t0.UnixMilli(), nil) + if got == nil { + t.Fatal("FindSince returned nil; want the 'mid' event") + } + if got.ID != "mid" { + t.Errorf("FindSince: got %q, want %q (the OLDEST event newer than the marker)", got.ID, "mid") + } +} + +func TestEventQueue_FindSince_ExclusiveOnTimestamp(t *testing.T) { + q := newEventQueue(10) + at := time.Unix(0, 0).Add(2000 * time.Millisecond) + q.Push(PaneEvent{ID: "exact-match", PaneID: "p1", Timestamp: at}) + + got := q.FindSince(at.UnixMilli(), nil) + if got != nil { + t.Errorf("event with timestamp == marker must NOT be returned (strict inequality); got %q", got.ID) + } +} + +func TestEventQueue_FindSince_RespectsPaneFilter(t *testing.T) { + q := newEventQueue(10) + t0 := time.Unix(0, 0).Add(1000 * time.Millisecond) + q.Push(PaneEvent{ID: "a", PaneID: "pane-A", Timestamp: t0.Add(10 * time.Millisecond)}) + q.Push(PaneEvent{ID: "b", PaneID: "pane-B", Timestamp: t0.Add(20 * time.Millisecond)}) + q.Push(PaneEvent{ID: "c", PaneID: "pane-A", Timestamp: t0.Add(30 * time.Millisecond)}) + + filter := map[string]bool{"pane-A": true} + got := q.FindSince(t0.UnixMilli(), filter) + if got == nil { + t.Fatal("FindSince should have matched pane-A") + } + if got.ID != "a" { + t.Errorf("FindSince filter: got %q, want %q (oldest matching pane-A)", got.ID, "a") + } +} + +func TestEventQueue_FindSince_NoMatchReturnsNil(t *testing.T) { + q := newEventQueue(10) + at := time.Unix(0, 0).Add(500 * time.Millisecond) + q.Push(PaneEvent{ID: "old", PaneID: "p1", Timestamp: at}) + + got := q.FindSince(at.Add(1*time.Hour).UnixMilli(), nil) + if got != nil { + t.Errorf("no event newer than far-future marker: got %q, want nil", got.ID) + } +} + +func TestEventQueue_FindSince_EmptyQueue(t *testing.T) { + q := newEventQueue(10) + if got := q.FindSince(0, nil); got != nil { + t.Errorf("empty queue: got %q, want nil", got.ID) + } +} diff --git a/internal/daemon/event_mute_test.go b/internal/daemon/event_mute_test.go new file mode 100644 index 0000000..5aef81c --- /dev/null +++ b/internal/daemon/event_mute_test.go @@ -0,0 +1,100 @@ +package daemon + +import ( + "testing" + + "github.com/artyomsv/quil/internal/config" + "github.com/artyomsv/quil/internal/ipc" +) + +// callUpdatePaneMute drives handleUpdatePane with just the Muted field set, +// mirroring how the TUI's toggleActivePaneMute sends the message. +func callUpdatePaneMute(d *Daemon, paneID string, muted bool) error { + msg, err := ipc.NewMessage(ipc.MsgUpdatePane, ipc.UpdatePanePayload{ + PaneID: paneID, + Muted: &muted, + }) + if err != nil { + return err + } + d.handleUpdatePane(msg) + return nil +} + +// TestEmitEvent_MutedPaneDropsEvent verifies that events sourced from a muted +// pane are neither queued nor broadcast. This is the contract behind the +// Alt+M keybinding — muting must be a *signal* mute, not a "hide in UI" mute. +func TestEmitEvent_MutedPaneDropsEvent(t *testing.T) { + d := New(config.Default()) + tab := &Tab{ID: "tab-1", Name: "test", Panes: []string{"pane-loud", "pane-quiet"}} + panes := []*Pane{ + {ID: "pane-loud", TabID: "tab-1", Type: "terminal"}, + {ID: "pane-quiet", TabID: "tab-1", Type: "terminal", Muted: true}, + } + d.session.RestoreTab(tab, panes) + + d.emitEvent(PaneEvent{ID: "evt-1", PaneID: "pane-loud", Type: "output_idle", Title: "Output idle"}) + d.emitEvent(PaneEvent{ID: "evt-2", PaneID: "pane-quiet", Type: "output_idle", Title: "Output idle"}) + + events := d.events.Events() + if len(events) != 1 { + t.Fatalf("queue length: got %d, want 1 (muted pane should not be queued)", len(events)) + } + if events[0].PaneID != "pane-loud" { + t.Errorf("only non-muted event should survive: got pane %q", events[0].PaneID) + } +} + +// TestEmitEvent_UnknownPaneStillEmits guards against an over-aggressive filter +// — events whose PaneID does not resolve to a live pane (e.g. a synthetic +// daemon-level event) must still be queued. +func TestEmitEvent_UnknownPaneStillEmits(t *testing.T) { + d := New(config.Default()) + d.emitEvent(PaneEvent{ID: "evt-orphan", PaneID: "pane-does-not-exist", Type: "bell"}) + if d.events.Count() != 1 { + t.Errorf("orphan event should still queue: got %d, want 1", d.events.Count()) + } +} + +// TestEmitEvent_EmptyPaneIDStillEmits ensures the mute filter does not gate +// daemon-level events that carry no PaneID at all. +func TestEmitEvent_EmptyPaneIDStillEmits(t *testing.T) { + d := New(config.Default()) + d.emitEvent(PaneEvent{ID: "evt-global", Type: "info"}) + if d.events.Count() != 1 { + t.Errorf("paneless event should still queue: got %d, want 1", d.events.Count()) + } +} + +// TestHandleUpdatePane_MutedFieldToggle drives MsgUpdatePane through the +// daemon's handler and asserts the Muted bit flips. Demonstrates the +// pointer-tristate contract: Name="" leaves Name alone, Muted=&true sets it. +func TestHandleUpdatePane_MutedFieldToggle(t *testing.T) { + d := New(config.Default()) + tab := &Tab{ID: "tab-1", Name: "test", Panes: []string{"pane-1"}} + panes := []*Pane{ + {ID: "pane-1", TabID: "tab-1", Name: "originalName"}, + } + d.session.RestoreTab(tab, panes) + + if panes[0].Muted { + t.Fatal("precondition: pane should start unmuted") + } + + if err := callUpdatePaneMute(d, "pane-1", true); err != nil { + t.Fatalf("update to muted=true: %v", err) + } + if !panes[0].Muted { + t.Errorf("after update: Muted should be true") + } + if panes[0].Name != "originalName" { + t.Errorf("Name should be preserved when only Muted is updated: got %q", panes[0].Name) + } + + if err := callUpdatePaneMute(d, "pane-1", false); err != nil { + t.Fatalf("update to muted=false: %v", err) + } + if panes[0].Muted { + t.Errorf("after second update: Muted should be false") + } +} diff --git a/internal/daemon/session.go b/internal/daemon/session.go index 43e504c..72ab769 100644 --- a/internal/daemon/session.go +++ b/internal/daemon/session.go @@ -44,8 +44,13 @@ type Pane struct { Rows int // Last known terminal height (0 = unknown) LastOutputAt time.Time // Updated on every flushPaneOutput IdleNotified bool // Prevents re-firing for same idle period - LastIdleEventAt time.Time // Cooldown: last time an idle event was emitted + LastIdleEventAt time.Time // Cooldown: last time a idle event was emitted LastBellEventAt time.Time // Cooldown: last time a bell event was emitted + // Muted suppresses notification events sourced from this pane. Set via + // MsgUpdatePane{Muted: true} from the TUI (default keybinding Alt+M). + // Persisted in the workspace snapshot so mute survives restart. Read + // under PluginMu in emitEvent. + Muted bool } type SessionManager struct { diff --git a/internal/ipc/protocol.go b/internal/ipc/protocol.go index 90671e0..162b059 100644 --- a/internal/ipc/protocol.go +++ b/internal/ipc/protocol.go @@ -161,6 +161,10 @@ type UpdatePanePayload struct { PaneID string `json:"pane_id"` Name string `json:"name,omitempty"` CWD string `json:"cwd,omitempty"` + // Muted is a pointer so an unset field (nil) is distinguishable from an + // explicit false. Callers updating only Name or CWD pass nil and the + // daemon leaves the pane's mute state untouched. + Muted *bool `json:"muted,omitempty"` } type UpdateLayoutPayload struct { @@ -314,6 +318,14 @@ type GetNotificationsRespPayload struct { type WatchNotificationsReqPayload struct { PaneIDs []string `json:"pane_ids,omitempty"` TimeoutMs int `json:"timeout_ms"` + // SinceTimestamp closes the race between "kick off a task" and "start + // watching" — events fired during that window would otherwise be lost. + // When set (Unix ms), the daemon first scans the existing event queue + // for any matching event whose timestamp is strictly greater, returning + // the oldest such event immediately. Only if the queue holds no + // qualifying event does it register a blocking watcher. Agents should + // pass the timestamp of the last event they handled. + SinceTimestamp int64 `json:"since_timestamp,omitempty"` } type WatchNotificationsRespPayload struct { diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index b8a8d0b..d6ad4af 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -450,6 +450,17 @@ func loadPluginTOML(path string) (*PanePlugin, error) { } // Convert notification handlers + // + // [[notification_handlers]] was the original per-chunk regex matcher. It + // proved too noisy (every PTY chunk triggered a scan) and was replaced by + // the lighter [[idle_handlers]] that runs only when a pane goes idle. + // MatchNotification still exists for back-compat with TOMLs in the wild + // but the daemon never calls it. Warn once per stale plugin so authors + // notice and migrate instead of silently editing dead config. + if len(tp.NotificationHandlers) > 0 { + log.Printf("plugin %q: [[notification_handlers]] is deprecated and no longer evaluated; "+ + "migrate to [[idle_handlers]] (same fields: pattern, title, severity)", tp.Plugin.Name) + } for _, nh := range tp.NotificationHandlers { p.NotificationHandlers = append(p.NotificationHandlers, NotificationHandler{ Pattern: nh.Pattern, diff --git a/internal/tui/model.go b/internal/tui/model.go index ac5bc32..5009b7d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -59,6 +59,7 @@ type PaneInfo struct { CWD string Name string Type string + Muted bool } // resizeTickMsg fires after the debounce delay; seq tracks freshness. @@ -705,7 +706,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case paneEventMsg: - m.notifications.AddEvent(ipc.PaneEventPayload(msg)) + // Skip output_idle events for the pane the user is currently looking + // at — it's redundant noise. Other event types (process_exit, bell, + // command_complete) stay even on the active pane: they're transient + // state changes that benefit from a sidebar audit trail. + if !(msg.Type == "output_idle" && m.isActivePane(msg.PaneID)) { + m.notifications.AddEvent(ipc.PaneEventPayload(msg)) + } cmds := []tea.Cmd{m.listenForMessages()} // Refresh sidebar tick if visible (no auto-show — user controls with Alt+N) if m.notifications.visible { @@ -1132,7 +1139,7 @@ func (m Model) notesKeyExempt(key string) bool { // Other modes. kb.FocusPane, // Notification center. - kb.NotificationToggle, kb.NotificationFocus, kb.GoBack, + kb.NotificationToggle, kb.NotificationFocus, kb.GoBack, kb.MutePane, // Tools and dialogs. kb.JSONTransform, kb.QuickActions, } @@ -1365,6 +1372,8 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(tea.ClearScreen, m.resizeAllPanes(), m.sidebarTick()) case kbMatches(key, kb.GoBack): return m.popPaneHistory() + case kbMatches(key, kb.MutePane): + return m, m.toggleActivePaneMute() } // Sidebar focused: route keys to notification center @@ -1819,6 +1828,7 @@ func (m *Model) applyWorkspaceState(state WorkspaceStateMsg) []string { leaf.Pane.Name = info.Name leaf.Pane.CWD = info.CWD leaf.Pane.Type = info.Type + leaf.Pane.Muted = info.Muted } } continue @@ -1844,6 +1854,7 @@ func (m *Model) applyWorkspaceState(state WorkspaceStateMsg) []string { pane.Name = info.Name pane.CWD = info.CWD pane.Type = info.Type + pane.Muted = info.Muted } // Try to fill a pending split placeholder first. @@ -2032,6 +2043,21 @@ func (m Model) activeTabModel() *TabModel { return nil } +// isActivePane reports whether paneID is the pane the user is currently +// focused on (active pane of the active tab). Used by the notification +// dispatcher to suppress redundant idle events for the pane the user is +// already staring at. +func (m Model) isActivePane(paneID string) bool { + if paneID == "" { + return false + } + tab := m.activeTabModel() + if tab == nil { + return false + } + return tab.ActivePane == paneID +} + // switchTab sets the active tab locally and notifies the daemon so its // active_tab stays in sync (prevents stale overwrites on broadcastState). func (m *Model) switchTab(idx int) tea.Cmd { @@ -2541,6 +2567,9 @@ func parseWorkspaceState(raw map[string]any) WorkspaceStateMsg { if typ, ok := pm["type"].(string); ok { pi.Type = typ } + if muted, ok := pm["muted"].(bool); ok { + pi.Muted = muted + } state.Panes = append(state.Panes, pi) } } @@ -3197,6 +3226,37 @@ func (m Model) updatePaneCWD(paneID, cwd string) tea.Cmd { } } +// toggleActivePaneMute flips the muted flag on the currently-focused pane and +// sends the update to the daemon. The daemon is the source of truth — it +// echoes the new state back via the next workspace_state broadcast and the +// pane border's `[muted]` chip updates from there. No-op if no active pane. +func (m Model) toggleActivePaneMute() tea.Cmd { + tab := m.activeTabModel() + if tab == nil { + return nil + } + pane := tab.ActivePaneModel() + if pane == nil { + return nil + } + next := !pane.Muted + paneID := pane.ID + return func() tea.Msg { + msg, err := ipc.NewMessage(ipc.MsgUpdatePane, ipc.UpdatePanePayload{ + PaneID: paneID, + Muted: &next, + }) + if err != nil { + log.Printf("toggleActivePaneMute build msg: %v", err) + return nil + } + if err := m.client.Send(msg); err != nil { + log.Printf("toggleActivePaneMute send: %v", err) + } + return nil + } +} + func (m Model) sendAllLayouts() tea.Cmd { return func() tea.Msg { for _, tab := range m.tabs { diff --git a/internal/tui/notification.go b/internal/tui/notification.go index 43f22f5..ced19ec 100644 --- a/internal/tui/notification.go +++ b/internal/tui/notification.go @@ -135,8 +135,11 @@ func (nc *NotificationCenter) View(height int) string { noEvents = truncateRunes(noEvents, innerW) lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Render(noEvents)) } else { - // Each event = separator + name/time + title = 3 lines - maxVisible := (innerH - 3) / 3 + // Each event = separator + name/time + title + excerpt = 4 lines. + // The excerpt line is always emitted (blank if Message is empty) so + // every event has the same height — keeps pagination math predictable. + const linesPerEvent = 4 + maxVisible := (innerH - 3) / linesPerEvent if maxVisible < 1 { maxVisible = 1 } @@ -193,8 +196,24 @@ func (nc *NotificationCenter) View(height int) string { titleText = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(titleText) } + // Line 3: excerpt — first non-empty line of Message (the + // triggering output). Dim grey so it visually subordinates to + // the title; blank line preserved if no excerpt so the + // per-event height stays constant. + excerptLine := "" + if e.Message != "" { + preview := " " + firstNonEmptyLine(e.Message) + preview = truncateRunes(preview, innerW) + if selected { + excerptLine = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Reverse(true).Render(preview) + } else { + excerptLine = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(preview) + } + } + lines = append(lines, line1) lines = append(lines, titleText) + lines = append(lines, excerptLine) } // Trailing separator @@ -231,6 +250,17 @@ func (nc *NotificationCenter) View(height int) string { Render(content) } +// firstNonEmptyLine returns the first non-empty trimmed line of s, or "". +// Used by the sidebar to render a one-line preview of a multi-line excerpt. +func firstNonEmptyLine(s string) string { + for _, line := range strings.Split(s, "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + return trimmed + } + } + return "" +} + // truncateRunes truncates a string to maxWidth runes. func truncateRunes(s string, maxWidth int) string { runes := []rune(s) diff --git a/internal/tui/notification_active_pane_test.go b/internal/tui/notification_active_pane_test.go new file mode 100644 index 0000000..e1f362e --- /dev/null +++ b/internal/tui/notification_active_pane_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "testing" + + "github.com/artyomsv/quil/internal/config" + "github.com/artyomsv/quil/internal/ipc" +) + +// modelForActivePaneTest builds a minimal Model with one tab containing one +// active pane. Enough surface area to exercise paneEventMsg dispatch. +func modelForActivePaneTest(activePaneID string) Model { + cfg := config.Default() + tab := NewTabModel("tab-1", "test") + pane := NewPaneModel(activePaneID, 1024) + tab.Root = NewLeaf(pane) + tab.ActivePane = activePaneID + return Model{ + client: &fakeSender{}, + tabs: []*TabModel{tab}, + activeTab: 0, + notifications: NewNotificationCenter(cfg.Notification.SidebarWidth, cfg.Notification.MaxEvents), + } +} + +func TestPaneEvent_OutputIdleOnActivePane_Suppressed(t *testing.T) { + m := modelForActivePaneTest("pane-active") + idle := paneEventMsg(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-active", + Type: "output_idle", + Title: "Output idle", + }) + next, _ := m.Update(idle) + got := next.(Model).notifications.Count() + if got != 0 { + t.Errorf("idle event on active pane should be suppressed; queue=%d, want 0", got) + } +} + +func TestPaneEvent_OutputIdleOnBackgroundPane_Queued(t *testing.T) { + m := modelForActivePaneTest("pane-active") + idle := paneEventMsg(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-background", + Type: "output_idle", + Title: "Output idle", + }) + next, _ := m.Update(idle) + got := next.(Model).notifications.Count() + if got != 1 { + t.Errorf("idle event on background pane should queue; queue=%d, want 1", got) + } +} + +func TestPaneEvent_ProcessExitOnActivePane_StillQueued(t *testing.T) { + // Process exits, bells, and command completions are transient state + // changes — they belong in the sidebar even when the user is looking at + // the pane (the sidebar acts as a session log they can scroll back to). + m := modelForActivePaneTest("pane-active") + exit := paneEventMsg(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-active", + Type: "process_exit", + Title: "Process exited (code 0)", + }) + next, _ := m.Update(exit) + got := next.(Model).notifications.Count() + if got != 1 { + t.Errorf("process_exit event must always queue (even on active pane); queue=%d, want 1", got) + } +} + +func TestPaneEvent_BellOnActivePane_StillQueued(t *testing.T) { + m := modelForActivePaneTest("pane-active") + bell := paneEventMsg(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-active", + Type: "bell", + Title: "Attention", + }) + next, _ := m.Update(bell) + got := next.(Model).notifications.Count() + if got != 1 { + t.Errorf("bell event must always queue (even on active pane); queue=%d, want 1", got) + } +} + +func TestIsActivePane_EmptyPaneID(t *testing.T) { + m := modelForActivePaneTest("pane-active") + if m.isActivePane("") { + t.Errorf("empty paneID must not match") + } +} + +func TestIsActivePane_NoActiveTab(t *testing.T) { + cfg := config.Default() + m := Model{ + client: &fakeSender{}, + tabs: nil, + activeTab: 0, + notifications: NewNotificationCenter(cfg.Notification.SidebarWidth, cfg.Notification.MaxEvents), + } + if m.isActivePane("anything") { + t.Errorf("no active tab: must return false, not panic") + } +} diff --git a/internal/tui/notification_excerpt_test.go b/internal/tui/notification_excerpt_test.go new file mode 100644 index 0000000..00f7d65 --- /dev/null +++ b/internal/tui/notification_excerpt_test.go @@ -0,0 +1,97 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/artyomsv/quil/internal/ipc" +) + +func TestFirstNonEmptyLine(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"single line", "hello", "hello"}, + {"multi line, first non-empty", "alpha\nbeta\n", "alpha"}, + {"leading blanks", "\n\n \nactual", "actual"}, + {"all blank", "\n\n\n", ""}, + {"empty", "", ""}, + {"whitespace trimmed", " hi \n", "hi"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := firstNonEmptyLine(tt.in); got != tt.want { + t.Errorf("firstNonEmptyLine(%q): got %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNotificationCenter_View_RendersExcerpt(t *testing.T) { + nc := NewNotificationCenter(40, 50) + nc.visible = true + nc.AddEvent(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-1", + PaneName: "build", + Type: "process_exit", + Title: "Process failed (code 1)", + Message: "Error: missing semicolon at line 42", + Severity: "error", + }) + + rendered := nc.View(20) + if !strings.Contains(rendered, "Error: missing semicolon") { + t.Errorf("View did not render excerpt; output:\n%s", rendered) + } + if !strings.Contains(rendered, "Process failed") { + t.Errorf("View did not render title; output:\n%s", rendered) + } +} + +func TestNotificationCenter_View_NoExcerptStillRendersTitle(t *testing.T) { + // Events without Message (legacy or empty-excerpt events) must still + // render the title — the excerpt slot is just left blank. + nc := NewNotificationCenter(40, 50) + nc.visible = true + nc.AddEvent(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-1", + PaneName: "shell", + Type: "bell", + Title: "Attention", + Severity: "warning", + }) + + rendered := nc.View(20) + if !strings.Contains(rendered, "Attention") { + t.Errorf("View did not render title; output:\n%s", rendered) + } +} + +func TestNotificationCenter_View_ExcerptShowsFirstLineOnly(t *testing.T) { + // A multi-line Message should collapse to its first non-empty line in + // the per-event sidebar card. Full text is still available via the + // daemon's Data["excerpt"] for MCP consumers. + nc := NewNotificationCenter(60, 50) + nc.visible = true + nc.AddEvent(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "pane-1", + PaneName: "ai", + Type: "output_idle", + Title: "Waiting for input", + Message: "first context line\nsecond line\nthird line", + Severity: "warning", + }) + + rendered := nc.View(20) + if !strings.Contains(rendered, "first context line") { + t.Errorf("View should contain first line; output:\n%s", rendered) + } + if strings.Contains(rendered, "second line") || strings.Contains(rendered, "third line") { + t.Errorf("View should not contain later excerpt lines; output:\n%s", rendered) + } +} diff --git a/internal/tui/pane.go b/internal/tui/pane.go index eb91947..9a3a59e 100644 --- a/internal/tui/pane.go +++ b/internal/tui/pane.go @@ -26,6 +26,7 @@ type PaneModel struct { Type string // plugin type ("terminal", "claude-code", etc.) Name string // user-given name (empty if not set) CWD string // current working directory from daemon + Muted bool // notification mute (daemon-authoritative; mirrored here for border rendering) vt *vt.SafeEmulator Width int Height int @@ -231,7 +232,18 @@ func (p *PaneModel) View() string { body := bodyStyle.Render(content) // Manual top border: CWD on the left, pane name on the right. - topLine := buildTopBorder(p.Width, p.CWD, p.Name, borderColor, p.ghost, p.resuming, p.preparing, p.focusMode, p.spinnerFrame) + // Muted panes prefix the right label so it's visible at a glance — the + // border colour stays the same (no risk of confusion with ghost / mcp / + // active states, each of which already owns a colour slot). + rightLabel := p.Name + if p.Muted { + if rightLabel == "" { + rightLabel = "[muted]" + } else { + rightLabel = "[muted] " + rightLabel + } + } + topLine := buildTopBorder(p.Width, p.CWD, rightLabel, borderColor, p.ghost, p.resuming, p.preparing, p.focusMode, p.spinnerFrame) return topLine + "\n" + body } From 8711c14db23fca55f95f74e6dce31740135060ef Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Fri, 5 Jun 2026 19:33:16 +0200 Subject: [PATCH 04/13] fix(notifications): ANSI boundary, prompt-only suppress, aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three signal-quality issues spotted while testing the sidebar: 1. ANSI sequence fragments leaked into excerpts. The trailing 4 KiB slice of the ring buffer could begin mid-CSI — the `\x1b[` ended up in the discarded prefix but parameters like `2;30;30;30m` or `;18H` survived into the window and ansi.Strip rendered them as plain text. New `trimToNewlineSafe` helper advances the slice start to the next newline before stripping, applied in both `paneOutputExcerpt` and `analyzeIdleTitle`. 2. Output_idle events whose excerpt collapses to a single shell prompt rune (%, $, >, ❯, #, ➜, λ, ») are now suppressed when the default "Output idle" title fired. Shells idling at a prompt are not a state change worth notifying — the user can see the prompt by looking at the pane. Plugin idle handlers (Claude "Needs your approval" etc.) still fire because their non-default title means the regex saw something meaningful. 3. eventQueue.Push aggregates repeat (PaneID, Title) events: the new event reuses the prior event's ID, bumps Data["count"], and replaces it at the front of the queue. The TUI sidebar's AddEvent updates the matching card in place and bubbles it to position 0. The title line now renders a ×N badge when count > 1. Replaces N near-duplicate cards saying the same thing with one bumping card. --- internal/daemon/daemon.go | 75 ++++++++++-- internal/daemon/event.go | 36 +++++- internal/daemon/event_aggregate_test.go | 108 ++++++++++++++++++ .../daemon/event_excerpt_followup_test.go | 98 ++++++++++++++++ internal/tui/notification.go | 26 ++++- internal/tui/notification_aggregate_test.go | 72 ++++++++++++ 6 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 internal/daemon/event_aggregate_test.go create mode 100644 internal/daemon/event_excerpt_followup_test.go create mode 100644 internal/tui/notification_aggregate_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 26f1a9e..229bf58 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1815,6 +1815,15 @@ func (d *Daemon) checkIdlePanes() { } title, severity, excerpt := d.analyzeIdleTitle(pane) + // Skip prompt-only idle events: shells legitimately idle at a + // shell prompt are not a state change worth notifying. We only + // suppress when the default "Output idle" title fired — if a + // plugin idle handler matched (e.g. claude-code's "Needs your + // approval"), the regex saw something meaningful in the excerpt + // even though the surface chars collapse to a prompt rune. + if title == "Output idle" && isPromptOnlyExcerpt(excerpt) { + continue + } d.emitEvent(withExcerpt(PaneEvent{ ID: uuid.New().String(), PaneID: pane.ID, @@ -1855,10 +1864,7 @@ func (d *Daemon) analyzeIdleTitle(pane *Pane) (title, severity, excerpt string) if len(raw) == 0 { return } - if len(raw) > 4096 { - raw = raw[len(raw)-4096:] - } - stripped := ansi.Strip(string(raw)) + stripped := ansi.Strip(string(trimToNewlineSafe(raw, 4096))) excerpt = lastNLines(stripped, 5) if len(p.IdleHandlers) == 0 || excerpt == "" { return @@ -1899,10 +1905,65 @@ func paneOutputExcerpt(pane *Pane, n int) string { if len(raw) == 0 { return "" } - if len(raw) > 4096 { - raw = raw[len(raw)-4096:] + return lastNLines(ansi.Strip(string(trimToNewlineSafe(raw, 4096))), n) +} + +// trimToNewlineSafe returns the trailing window of raw, advancing past any +// partial ANSI escape sequence at the slice boundary. Without this guard, a +// 4 KiB tail slice can begin in the middle of a CSI sequence — the leading +// `\x1b[` ended up in the discarded prefix, but parameters like +// `2;30;30;30m` or `;18H` survive into the window and ansi.Strip can no +// longer recognise them as part of an escape. They then render to the user +// as raw garbage. +// +// The advance is bounded: we look at most one line ahead and bail out if no +// newline is found (which only happens on pathological no-newline output, +// where the partial-sequence cost is at most a few characters of garbage and +// not worth dropping the whole window for). +func trimToNewlineSafe(raw []byte, maxTail int) []byte { + if len(raw) <= maxTail { + return raw + } + start := len(raw) - maxTail + if idx := bytes.IndexByte(raw[start:], '\n'); idx >= 0 { + start += idx + 1 + } + return raw[start:] +} + +// promptRunes are the canonical interactive shell prompt terminators. +// An idle excerpt that strips down to one of these (and nothing else) means +// the pane is sitting at a fresh prompt — a non-event from the user's POV, +// because they can see the prompt by looking at the pane. +var promptRunes = map[string]bool{ + "%": true, // zsh default + "$": true, // bash / sh + ">": true, // PowerShell / cmd, also some Python REPLs + "❯": true, // starship / pure / spaceship default + "#": true, // root prompts + "➜": true, // oh-my-zsh agnoster / af-magic + "λ": true, // fish-friendly minimal themes + "»": true, // bash-it powerline +} + +// isPromptOnlyExcerpt reports whether an excerpt collapses to a bare shell +// prompt — i.e. the only non-whitespace content is one prompt rune, possibly +// with leading/trailing space. Multi-line excerpts where ALL non-empty lines +// are prompt-only also qualify (a sequence of empty prompts). +func isPromptOnlyExcerpt(excerpt string) bool { + if excerpt == "" { + return false // empty excerpt is a separate case — keep emitting + } + for _, line := range strings.Split(excerpt, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if !promptRunes[trimmed] { + return false + } } - return lastNLines(ansi.Strip(string(raw)), n) + return true } // withExcerpt populates PaneEvent.Message and Data["excerpt"] from the pane's diff --git a/internal/daemon/event.go b/internal/daemon/event.go index 1b07720..6aae1c4 100644 --- a/internal/daemon/event.go +++ b/internal/daemon/event.go @@ -1,6 +1,7 @@ package daemon import ( + "strconv" "sync" "time" @@ -46,11 +47,44 @@ func newEventQueue(max int) *eventQueue { } } -// Push adds an event (newest first) and wakes any matching watchers. +// Push adds an event (newest first), aggregating repeat events from the same +// pane with the same title, and wakes any matching watchers. +// +// Aggregation: when a queued event has matching (PaneID, Title), the new +// event REPLACES it at the front of the queue. The replacement keeps the +// old event's ID (so the TUI sidebar can update its card in place via the +// existing ID-based dedup) and bumps Data["count"]. This collapses bursts +// of "Output idle" from the same shell, or repeated "Waiting for input" +// from the same Claude pane, into a single sidebar card with a ×N badge — +// instead of N separate cards saying the same thing. +// +// Aggregation only applies when PaneID is non-empty so daemon-level events +// (without a pane source) never collapse together. func (q *eventQueue) Push(e PaneEvent) { q.mu.Lock() defer q.mu.Unlock() + if e.PaneID != "" && e.Title != "" { + for i, existing := range q.events { + if existing.PaneID == e.PaneID && existing.Title == e.Title { + count := 1 + if existing.Data != nil { + if c, err := strconv.Atoi(existing.Data["count"]); err == nil && c > 0 { + count = c + } + } + count++ + e.ID = existing.ID + if e.Data == nil { + e.Data = make(map[string]string) + } + e.Data["count"] = strconv.Itoa(count) + q.events = append(q.events[:i], q.events[i+1:]...) + break + } + } + } + q.events = append([]PaneEvent{e}, q.events...) if len(q.events) > q.max { q.events = q.events[:q.max] diff --git a/internal/daemon/event_aggregate_test.go b/internal/daemon/event_aggregate_test.go new file mode 100644 index 0000000..c7b0097 --- /dev/null +++ b/internal/daemon/event_aggregate_test.go @@ -0,0 +1,108 @@ +package daemon + +import ( + "testing" + "time" +) + +// TestEventQueue_Push_AggregatesSameTitleSamePane proves the field-observed +// "two pane-a39ad0c Waiting for input cards" issue is now collapsed into one +// card with a count badge. +func TestEventQueue_Push_AggregatesSameTitleSamePane(t *testing.T) { + q := newEventQueue(10) + t0 := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ID: "first-id", PaneID: "p1", Title: "Waiting for input", Timestamp: t0}) + q.Push(PaneEvent{ID: "second-id", PaneID: "p1", Title: "Waiting for input", Timestamp: t0.Add(30 * time.Second)}) + + events := q.Events() + if len(events) != 1 { + t.Fatalf("expected 1 aggregated entry, got %d", len(events)) + } + got := events[0] + if got.ID != "first-id" { + t.Errorf("aggregation must reuse the older ID so the TUI updates in place; got %q, want %q", got.ID, "first-id") + } + if got.Data["count"] != "2" { + t.Errorf("count after one aggregation: got %q, want %q", got.Data["count"], "2") + } + if !got.Timestamp.Equal(t0.Add(30 * time.Second)) { + t.Errorf("aggregated timestamp should be the newer one; got %v, want %v", got.Timestamp, t0.Add(30*time.Second)) + } +} + +func TestEventQueue_Push_AggregationKeepsCountingAcrossManyHits(t *testing.T) { + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + for i := 0; i < 5; i++ { + q.Push(PaneEvent{ + ID: "evt-" + string(rune('a'+i)), + PaneID: "p1", + Title: "Output idle", + Timestamp: at.Add(time.Duration(i) * time.Second), + }) + } + events := q.Events() + if len(events) != 1 { + t.Fatalf("five same-pane same-title pushes must collapse to one entry; got %d", len(events)) + } + if events[0].Data["count"] != "5" { + t.Errorf("count after five aggregations: got %q, want %q", events[0].Data["count"], "5") + } +} + +func TestEventQueue_Push_DifferentTitleNotAggregated(t *testing.T) { + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ID: "a", PaneID: "p1", Title: "Output idle", Timestamp: at}) + q.Push(PaneEvent{ID: "b", PaneID: "p1", Title: "Process exited (code 0)", Timestamp: at.Add(time.Second)}) + + if got := q.Count(); got != 2 { + t.Errorf("different titles must remain distinct; queue=%d, want 2", got) + } +} + +func TestEventQueue_Push_DifferentPaneNotAggregated(t *testing.T) { + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ID: "a", PaneID: "p1", Title: "Output idle", Timestamp: at}) + q.Push(PaneEvent{ID: "b", PaneID: "p2", Title: "Output idle", Timestamp: at.Add(time.Second)}) + + if got := q.Count(); got != 2 { + t.Errorf("different panes must remain distinct; queue=%d, want 2", got) + } +} + +func TestEventQueue_Push_EmptyPaneIDNeverAggregates(t *testing.T) { + // Daemon-level events without a pane source must remain distinct so + // they're never accidentally collapsed. + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ID: "a", PaneID: "", Title: "ping", Timestamp: at}) + q.Push(PaneEvent{ID: "b", PaneID: "", Title: "ping", Timestamp: at.Add(time.Second)}) + + if got := q.Count(); got != 2 { + t.Errorf("paneless events must not aggregate; queue=%d, want 2", got) + } +} + +func TestEventQueue_Push_AggregationMovesToFront(t *testing.T) { + // Insert: A(p1), B(p2), then A again with same (p1, Output idle). The + // repeat must end up at position 0, not at its old position. + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ID: "a1", PaneID: "p1", Title: "Output idle", Timestamp: at}) + q.Push(PaneEvent{ID: "b1", PaneID: "p2", Title: "Output idle", Timestamp: at.Add(time.Second)}) + q.Push(PaneEvent{ID: "a2", PaneID: "p1", Title: "Output idle", Timestamp: at.Add(2 * time.Second)}) + + events := q.Events() + if len(events) != 2 { + t.Fatalf("a1+a2 collapses; queue should hold {a-aggregated, b}; got %d entries", len(events)) + } + // Newest first: aggregated p1 first, then p2. + if events[0].PaneID != "p1" { + t.Errorf("aggregated event should bubble to position 0; got pane %q", events[0].PaneID) + } + if events[0].Data["count"] != "2" { + t.Errorf("aggregated count: got %q, want %q", events[0].Data["count"], "2") + } +} diff --git a/internal/daemon/event_excerpt_followup_test.go b/internal/daemon/event_excerpt_followup_test.go new file mode 100644 index 0000000..1c500bf --- /dev/null +++ b/internal/daemon/event_excerpt_followup_test.go @@ -0,0 +1,98 @@ +package daemon + +import ( + "strings" + "testing" + + "github.com/artyomsv/quil/internal/ringbuf" +) + +// TestTrimToNewlineSafe_AdvancesPastPartialANSI proves the bug-fix: when the +// trailing-window slice begins inside an ANSI escape sequence, advance to +// the next newline so the parameter bytes never reach ansi.Strip as plain +// text. Reproduces the field-observed garbage where excerpts contained +// "2;30;30;30m" (fragment of \x1b[2;30;30;30m) at the start. +func TestTrimToNewlineSafe_AdvancesPastPartialANSI(t *testing.T) { + // Craft a buffer where the maxTail slice would otherwise begin inside + // an ANSI sequence. Layout: \x1b[31mRED\n + prefix := strings.Repeat("x", 100) + // Truncate the ANSI sequence at the tail boundary deliberately. + body := "\x1b[2;30;30;30mPARAM-LEAK\nsafe-line\n" + raw := []byte(prefix + body) + // maxTail = len(body) - 5 starts five bytes INTO the escape sequence — + // past the `\x1b[` but before `m`. Mirrors the field bug shape. + tailLen := len(body) - 5 + + got := trimToNewlineSafe(raw, tailLen) + gotStr := string(got) + + if strings.Contains(gotStr, "PARAM-LEAK") { + t.Errorf("trimToNewlineSafe leaked the half-sequence text: %q", gotStr) + } + if !strings.Contains(gotStr, "safe-line") { + t.Errorf("trimToNewlineSafe dropped the safe trailing line: %q", gotStr) + } +} + +func TestTrimToNewlineSafe_NoTrimWhenSmaller(t *testing.T) { + raw := []byte("short data") + got := trimToNewlineSafe(raw, 4096) + if string(got) != "short data" { + t.Errorf("trimToNewlineSafe small input: got %q, want %q", got, "short data") + } +} + +func TestTrimToNewlineSafe_NoNewlineFallback(t *testing.T) { + // Pathological case: no newline in the window. We accept some leading + // garbage rather than dropping the whole window — small loss compared + // to silently emitting nothing. + raw := []byte(strings.Repeat("a", 5000)) + got := trimToNewlineSafe(raw, 4096) + if len(got) != 4096 { + t.Errorf("trimToNewlineSafe no-newline fallback: got len=%d, want 4096", len(got)) + } +} + +func TestPaneOutputExcerpt_FieldReproducesAndFixesANSILeak(t *testing.T) { + // Reproduce the screenshot bug end-to-end. The ring buffer holds a + // stream of bytes that exceeds 4096; the trailing slice would have + // previously begun inside the CSI parameters and leaked them. + pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(8192)} + pad := strings.Repeat("filler-padding-line\n", 200) // ~4 KiB of harmless padding + tail := "\x1b[2;30;30;30mClaude prompt\n\x1b[31merror line\n" + pane.OutputBuf.Write([]byte(pad + tail)) + + got := paneOutputExcerpt(pane, 3) + if strings.Contains(got, "2;30;30;30m") || strings.Contains(got, ";30m") { + t.Errorf("ANSI parameter fragments leaked into excerpt: %q", got) + } +} + +func TestIsPromptOnlyExcerpt(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"empty is not prompt-only", "", false}, + {"zsh percent", "%", true}, + {"bash dollar", "$", true}, + {"powershell gt", ">", true}, + {"starship arrow", "❯", true}, + {"root hash", "#", true}, + {"agnoster arrow", "➜", true}, + {"prompt with surrounding whitespace", " % ", true}, + {"prompt across multiple lines", "$\n\n%\n", true}, + {"meaningful first line", "error: missing semicolon\n%", false}, + {"meaningful last line", "%\nbuilding...", false}, + {"long word", "%cargo", false}, + {"text resembling prompt then content", "%%", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isPromptOnlyExcerpt(tt.input); got != tt.want { + t.Errorf("isPromptOnlyExcerpt(%q): got %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/tui/notification.go b/internal/tui/notification.go index ced19ec..84e5d45 100644 --- a/internal/tui/notification.go +++ b/internal/tui/notification.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strconv" "strings" "time" @@ -30,10 +31,17 @@ func NewNotificationCenter(width, maxEvents int) *NotificationCenter { return &NotificationCenter{width: width, maxEvents: maxEvents} } -// AddEvent prepends an event, deduplicating by ID. +// AddEvent prepends an event. When an event with the same ID is already +// queued, the entry is updated in place AND moved to the front — this is +// the echo of the daemon's eventQueue.Push aggregation, where a repeat +// (PaneID, Title) event reuses the prior event's ID and bumps Data["count"]. +// Without the move-to-front the sidebar would silently drop bumps and the +// user would never see the ×N count grow. func (nc *NotificationCenter) AddEvent(e ipc.PaneEventPayload) { - for _, existing := range nc.events { + for i, existing := range nc.events { if existing.ID == e.ID { + nc.events = append(nc.events[:i], nc.events[i+1:]...) + nc.events = append([]ipc.PaneEventPayload{e}, nc.events...) return } } @@ -187,9 +195,17 @@ func (nc *NotificationCenter) View(height int) string { } line1 := styledName + strings.Repeat(" ", gap) + lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Render(age) - // Line 2: title (indented) - titleText := " " + e.Title - titleText = truncateRunes(titleText, innerW) + // Line 2: title (indented), with optional ×N badge for + // daemon-side aggregation. count > 1 means this card already + // absorbed N repeats of the same (PaneID, Title). + titleBody := " " + e.Title + countBadge := "" + if e.Data != nil { + if n, err := strconv.Atoi(e.Data["count"]); err == nil && n > 1 { + countBadge = " ×" + e.Data["count"] + } + } + titleText := truncateRunes(titleBody+countBadge, innerW) if selected { titleText = lipgloss.NewStyle().Reverse(true).Render(titleText) } else { diff --git a/internal/tui/notification_aggregate_test.go b/internal/tui/notification_aggregate_test.go new file mode 100644 index 0000000..0ad39d8 --- /dev/null +++ b/internal/tui/notification_aggregate_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/artyomsv/quil/internal/ipc" +) + +// TestNotificationCenter_AddEvent_SameIDUpdatesInPlace mirrors the daemon's +// aggregation echo: a repeat (PaneID, Title) event reuses the prior event's +// ID and the sidebar must update the existing card in place AND bubble it +// to the front (so users see the bump and ×N badge change). +func TestNotificationCenter_AddEvent_SameIDUpdatesInPlace(t *testing.T) { + nc := NewNotificationCenter(40, 50) + nc.AddEvent(ipc.PaneEventPayload{ID: "shared", PaneID: "p1", Title: "Output idle", Message: "first"}) + nc.AddEvent(ipc.PaneEventPayload{ID: "other", PaneID: "p2", Title: "Output idle", Message: "other"}) + nc.AddEvent(ipc.PaneEventPayload{ + ID: "shared", + PaneID: "p1", + Title: "Output idle", + Message: "second", + Data: map[string]string{"count": "2"}, + }) + + if nc.Count() != 2 { + t.Fatalf("count after update-in-place: got %d, want 2", nc.Count()) + } + if nc.events[0].ID != "shared" { + t.Errorf("aggregated card must bubble to position 0; got %q", nc.events[0].ID) + } + if nc.events[0].Message != "second" { + t.Errorf("update-in-place must replace content; got %q, want %q", nc.events[0].Message, "second") + } + if nc.events[0].Data["count"] != "2" { + t.Errorf("update must preserve count; got %q, want %q", nc.events[0].Data["count"], "2") + } +} + +func TestNotificationCenter_View_RendersCountBadge(t *testing.T) { + nc := NewNotificationCenter(40, 50) + nc.visible = true + nc.AddEvent(ipc.PaneEventPayload{ + ID: "evt-1", + PaneID: "p1", + Title: "Waiting for input", + Message: "claude prompt", + Data: map[string]string{"count": "7"}, + }) + + rendered := nc.View(20) + if !strings.Contains(rendered, "×7") { + t.Errorf("View must render ×N badge when count > 1; output:\n%s", rendered) + } + if !strings.Contains(rendered, "Waiting for input") { + t.Errorf("View must still render title; output:\n%s", rendered) + } +} + +func TestNotificationCenter_View_NoBadgeWhenCountOne(t *testing.T) { + nc := NewNotificationCenter(40, 50) + nc.visible = true + nc.AddEvent(ipc.PaneEventPayload{ + ID: "evt-1", + Title: "Output idle", + }) + + rendered := nc.View(20) + if strings.Contains(rendered, "×") { + t.Errorf("View must NOT render ×N for a single un-aggregated event; output:\n%s", rendered) + } +} From 01d2d702b1a6460852dd03b0b85b1c29dee8a42d Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Fri, 5 Jun 2026 21:19:39 +0200 Subject: [PATCH 05/13] fix(notifications): seek ESC byte in ANSI trim, log idle decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test surfaced two issues my first round of follow-ups missed: 1. ANSI fragments STILL leaked in claude-code panes (`2;30;30;30m| Thinking: ...`, `0mConfig/Build files:- ...`). Root cause: the newline-only seek in trimToNewlineSafe fell through on TUIs that emit one screen paint with no newline inside the trailing 4 KiB window. The bounded scan now also accepts an ESC byte (0x1b) as a clean boundary — ansi.Strip handles a full sequence starting at ESC, even if the next newline is far away. Scan is bounded to 512 bytes so it stays cheap. 2. Output-idle events with `%` excerpts kept firing — both panes had their cards collapsed to `×2` even though my isPromptOnlyExcerpt covers the bare `%` case. The screenshot shows what the sidebar renders (first non-empty line), not what the daemon's idle checker sees (the full 5-line excerpt). Add a debug-level log line at every idle decision (pane id, type, title, suppress verdict, excerpt prefix) so we can see why the check returns false on real-world shells. With the dev build's ldflags-forced debug level, this surfaces in the next test run's quild.log. --- internal/daemon/daemon.go | 45 ++++++++++-- .../daemon/event_excerpt_followup_test.go | 72 +++++++++++++++++-- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 229bf58..4493527 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1821,7 +1821,18 @@ func (d *Daemon) checkIdlePanes() { // plugin idle handler matched (e.g. claude-code's "Needs your // approval"), the regex saw something meaningful in the excerpt // even though the surface chars collapse to a prompt rune. - if title == "Output idle" && isPromptOnlyExcerpt(excerpt) { + suppress := title == "Output idle" && isPromptOnlyExcerpt(excerpt) + // Debug visibility into what excerpts the idle checker sees in + // the wild — helps diagnose why suppression fires (or doesn't) + // for a given shell setup. Truncate the excerpt to keep the + // log line bounded. + logExcerpt := excerpt + if len(logExcerpt) > 200 { + logExcerpt = logExcerpt[:200] + "..." + } + logger.Debug("idle decision: pane=%s type=%s title=%q suppress=%v excerpt=%q", + pane.ID, pane.Type, title, suppress, logExcerpt) + if suppress { continue } d.emitEvent(withExcerpt(PaneEvent{ @@ -1916,17 +1927,37 @@ func paneOutputExcerpt(pane *Pane, n int) string { // longer recognise them as part of an escape. They then render to the user // as raw garbage. // -// The advance is bounded: we look at most one line ahead and bail out if no -// newline is found (which only happens on pathological no-newline output, -// where the partial-sequence cost is at most a few characters of garbage and -// not worth dropping the whole window for). +// We scan forward bounded by maxScan bytes looking for either: +// - a newline (clean text restart), or +// - an ESC byte (0x1b — start of a fresh ANSI sequence that ansi.Strip +// will recognise in full). +// +// Whichever boundary comes first wins. Newline-only seek wasn't enough: +// some TUIs (Claude Code, opencode) emit one logical "screen paint" with +// few or no newlines in the trailing window, so the seek fell through and +// we returned the un-advanced slice — the original bug shape. ESC bytes +// are abundant in ANSI-rich panes, so finding one is fast. +// +// If neither boundary is found within maxScan, we accept the un-advanced +// slice — the chance of a leading partial sequence in 4 KiB of plain text +// is small relative to the bytes the user sees. func trimToNewlineSafe(raw []byte, maxTail int) []byte { if len(raw) <= maxTail { return raw } start := len(raw) - maxTail - if idx := bytes.IndexByte(raw[start:], '\n'); idx >= 0 { - start += idx + 1 + const maxScan = 512 + upper := start + maxScan + if upper > len(raw) { + upper = len(raw) + } + for i := start; i < upper; i++ { + switch raw[i] { + case '\n': + return raw[i+1:] + case 0x1b: + return raw[i:] + } } return raw[start:] } diff --git a/internal/daemon/event_excerpt_followup_test.go b/internal/daemon/event_excerpt_followup_test.go index 1c500bf..8812c72 100644 --- a/internal/daemon/event_excerpt_followup_test.go +++ b/internal/daemon/event_excerpt_followup_test.go @@ -43,13 +43,77 @@ func TestTrimToNewlineSafe_NoTrimWhenSmaller(t *testing.T) { } func TestTrimToNewlineSafe_NoNewlineFallback(t *testing.T) { - // Pathological case: no newline in the window. We accept some leading - // garbage rather than dropping the whole window — small loss compared - // to silently emitting nothing. + // Pathological case: no newline AND no ESC in the bounded scan + // window. We accept some leading garbage rather than dropping the + // whole window — small loss compared to silently emitting nothing. raw := []byte(strings.Repeat("a", 5000)) got := trimToNewlineSafe(raw, 4096) if len(got) != 4096 { - t.Errorf("trimToNewlineSafe no-newline fallback: got len=%d, want 4096", len(got)) + t.Errorf("trimToNewlineSafe no-boundary fallback: got len=%d, want 4096", len(got)) + } +} + +// TestTrimToNewlineSafe_SeeksESCWhenNoNewline reproduces the field bug shape: +// Claude TUI emits one big screen paint with very few newlines, so the +// newline-only seek falls through and leaves leading ANSI parameter bytes +// in the slice. The hardened version also recognises ESC bytes as a clean +// boundary because ansi.Strip handles full sequences from there. +func TestTrimToNewlineSafe_SeeksESCWhenNoNewline(t *testing.T) { + // 100 bytes of CSI parameter garbage (no newline, no ESC), then an ESC + // starting a fresh sequence + real content. + prefix := strings.Repeat("a", 100) + tail := "2;30;30;30m garbage chars no newline " + "\x1b[31mreal content here" + raw := []byte(prefix + tail) + tailLen := len(tail) - 5 // boundary lands inside the garbage prefix + + got := trimToNewlineSafe(raw, tailLen) + gotStr := string(got) + + if strings.Contains(gotStr, "garbage chars") { + t.Errorf("trimToNewlineSafe should have advanced past garbage to the ESC: %q", gotStr) + } + if !strings.Contains(gotStr, "real content here") { + t.Errorf("trimToNewlineSafe should have preserved the post-ESC content: %q", gotStr) + } + if got[0] != 0x1b { + t.Errorf("first byte after seek should be ESC; got 0x%02x", got[0]) + } +} + +// TestTrimToNewlineSafe_NewlineBeatsESCWhenFirst confirms whichever boundary +// comes first wins (newline before ESC in this case). +func TestTrimToNewlineSafe_NewlineBeatsESCWhenFirst(t *testing.T) { + prefix := strings.Repeat("a", 100) + tail := "garbage\nline-after-newline\x1b[31mansi-later" + raw := []byte(prefix + tail) + tailLen := len(tail) - 5 + + got := trimToNewlineSafe(raw, tailLen) + gotStr := string(got) + + if strings.Contains(gotStr, "garbage") { + t.Errorf("trimToNewlineSafe should have advanced past the newline: %q", gotStr) + } + if !strings.HasPrefix(gotStr, "line-after-newline") { + t.Errorf("trimToNewlineSafe should start at next line, not skip to ESC: %q", gotStr) + } +} + +// TestTrimToNewlineSafe_ScanBoundary asserts that the scan stops at maxScan +// bytes — far-away boundaries don't drag the whole window forward. +func TestTrimToNewlineSafe_ScanBoundary(t *testing.T) { + // 2 KiB of plain text (no boundary), then a newline. Scan window is + // 512 bytes, so the seek must not find the newline and must return + // the un-advanced slice. + prefix := strings.Repeat("a", 100) + pad := strings.Repeat("b", 2000) + tail := pad + "\nafter-far-newline" + raw := []byte(prefix + tail) + tailLen := len(tail) - 50 + + got := trimToNewlineSafe(raw, tailLen) + if len(got) != tailLen { + t.Errorf("scan should not advance past maxScan; got len=%d, want %d", len(got), tailLen) } } From 5340412ac4ed0ff58506ed976710484eb5c95ccc Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Fri, 5 Jun 2026 21:30:02 +0200 Subject: [PATCH 06/13] fix(notifications): CR-reset cleanup + OSC hostname leak as prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field diagnosis with debug logs confirmed the real shape of the bug. The terminal-pane idle excerpts logged in quild.log look like: "% [padding] \r \r\rArtjoms_Stukans@EPCHZURW03..." That's: prompt rune, padding spaces, CR-return-to-start, more CRs, then OSC 0 window-title content that leaks through ansi.Strip (the strip state machine appears to bail on an embedded CR inside the OSC payload). The sidebar then renders just "%" because that's the first non-empty line — but the *actual* terminal-visible content is the hostname leak, not the prompt that was immediately overwritten by the CRs. Two coordinated changes: 1. lastNLines now applies per-line CR-reset semantics — splitting a line at the last `\r` and keeping only the trailing segment. This matches what the terminal would actually display, so excerpts no longer capture text the user can never see (the pre-CR prompt) and miss the text they DO see (the post-CR window title). 2. isPromptOnlyExcerpt is now suffix-based and recognises hostname-like patterns. A line is "prompt-like" when, after trimming, it ends with a recognised prompt rune (covers `%`, `user@host % `, `~/repo $ `, etc) OR matches a user@host pattern (the OSC 0 leak signature). Long lines (> 200 chars) are presumed to be command output regardless of trailing characters — real prompts are short. The two changes interact: after CR-reset the excerpt's visible content is the hostname leak; the new prompt classifier recognises that pattern and suppresses correctly. `ls` output still survives suppression because its line endings don't match prompt patterns. Verified against the field-logged excerpts. --- internal/daemon/daemon.go | 96 +++++++++++++++---- .../daemon/event_excerpt_followup_test.go | 34 ++++++- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4493527..c680984 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1887,12 +1887,22 @@ func (d *Daemon) analyzeIdleTitle(pane *Pane) (title, severity, excerpt string) return } -// lastNLines returns the last n non-empty lines from text. +// lastNLines returns the last n non-empty lines from text, applying terminal +// carriage-return semantics per line. A real terminal interprets `\r` as +// "return to column 0 and overwrite from there" — so when ansi.Strip leaves +// `prompt \r \r\rwindow-title-leak` in a single line, what the user +// actually SEES is the trailing segment after the last `\r`. Without this +// reset, excerpts capture text the user can never see (e.g. the prompt +// rune that was immediately overwritten) and miss the text they DO see. func lastNLines(text string, n int) string { lines := strings.Split(text, "\n") var result []string for i := len(lines) - 1; i >= 0 && len(result) < n; i-- { - trimmed := strings.TrimSpace(lines[i]) + line := lines[i] + if cr := strings.LastIndex(line, "\r"); cr >= 0 { + line = line[cr+1:] + } + trimmed := strings.TrimSpace(line) if trimmed != "" { result = append([]string{trimmed}, result...) } @@ -1967,34 +1977,82 @@ func trimToNewlineSafe(raw []byte, maxTail int) []byte { // the pane is sitting at a fresh prompt — a non-event from the user's POV, // because they can see the prompt by looking at the pane. var promptRunes = map[string]bool{ - "%": true, // zsh default - "$": true, // bash / sh - ">": true, // PowerShell / cmd, also some Python REPLs - "❯": true, // starship / pure / spaceship default - "#": true, // root prompts - "➜": true, // oh-my-zsh agnoster / af-magic - "λ": true, // fish-friendly minimal themes - "»": true, // bash-it powerline -} - -// isPromptOnlyExcerpt reports whether an excerpt collapses to a bare shell -// prompt — i.e. the only non-whitespace content is one prompt rune, possibly -// with leading/trailing space. Multi-line excerpts where ALL non-empty lines -// are prompt-only also qualify (a sequence of empty prompts). + "%": true, // zsh default + "$": true, // bash / sh + ">": true, // PowerShell / cmd, also some Python REPLs + "❯": true, // starship / pure / spaceship default + "#": true, // root prompts + "➜": true, // oh-my-zsh agnoster / af-magic + "λ": true, // fish-friendly minimal themes + "»": true, // bash-it powerline +} + +// hostnameLikeRe matches user@host patterns (e.g. "Artjoms_Stukans@HOSTNAME") +// that leak into excerpts from OSC 0 window-title sequences when ansi.Strip +// or upstream emulators bail on an embedded CR. These leaks are +// indistinguishable from "the pane is at a prompt" because the underlying +// terminal state IS a fresh prompt — the title text is what survived the +// strip, not what the cursor is sitting on. +var hostnameLikeRe = regexp.MustCompile(`^[\w][\w.-]*@[\w][\w.-]+`) + +// isPromptOnlyExcerpt reports whether the excerpt represents a pane sitting +// at an idle shell prompt. We classify a line as "prompt-like" when it is: +// +// - a single canonical prompt rune (`%`, `$`, `❯`, etc.), OR +// - short (< 200 chars) AND contains a prompt rune somewhere (e.g. +// "user@host % git:(main)"), OR +// - short AND starts with a user@host pattern — the OSC 0 leak signature. +// +// The excerpt is prompt-only when every non-empty line passes these checks. +// "Short" matters: a multi-line `ls` output that happens to contain a `%` +// in one filename should NOT collapse to "shell idle" — only lines that +// could realistically be a prompt qualify. func isPromptOnlyExcerpt(excerpt string) bool { if excerpt == "" { - return false // empty excerpt is a separate case — keep emitting + return false } + sawAny := false for _, line := range strings.Split(excerpt, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } - if !promptRunes[trimmed] { + sawAny = true + if !isPromptLikeLine(trimmed) { return false } } - return true + return sawAny +} + +// isPromptLikeLine encapsulates the per-line classification used by +// isPromptOnlyExcerpt. See that function's docs for the classification rules. +// +// Specifically, a line is "prompt-like" when, after trimming trailing +// whitespace, it ENDS with a recognised prompt rune (covers `%`, `user@host +// % `, `~/repo $ `, etc) OR it matches the user@host pattern that OSC 0 +// window-title leaks produce. Long lines (> 200 chars) are presumed to be +// command output regardless of trailing chars — real prompts are short. +func isPromptLikeLine(line string) bool { + if line == "" { + return true + } + if promptRunes[line] { + return true + } + if len(line) > 200 { + return false + } + trimmed := strings.TrimRight(line, " \t") + for r := range promptRunes { + if strings.HasSuffix(trimmed, r) { + return true + } + } + if hostnameLikeRe.MatchString(line) { + return true + } + return false } // withExcerpt populates PaneEvent.Message and Data["excerpt"] from the pane's diff --git a/internal/daemon/event_excerpt_followup_test.go b/internal/daemon/event_excerpt_followup_test.go index 8812c72..c651e98 100644 --- a/internal/daemon/event_excerpt_followup_test.go +++ b/internal/daemon/event_excerpt_followup_test.go @@ -132,6 +132,28 @@ func TestPaneOutputExcerpt_FieldReproducesAndFixesANSILeak(t *testing.T) { } } +// TestLastNLines_AppliesCarriageReturnReset proves that the field-observed +// "% ... \r \r\rArtjoms_Stukans@HOSTNAME" excerpt collapses to just +// the post-CR content (what the terminal would actually display), not the +// prompt rune that was immediately overwritten. +func TestLastNLines_AppliesCarriageReturnReset(t *testing.T) { + in := "% \r \r\rArtjoms_Stukans@EPCHZURW03: /path" + got := lastNLines(in, 5) + want := "Artjoms_Stukans@EPCHZURW03: /path" + if got != want { + t.Errorf("lastNLines CR-reset: got %q, want %q", got, want) + } +} + +func TestLastNLines_NoCarriageReturnUntouched(t *testing.T) { + in := "line one\nline two\nline three" + got := lastNLines(in, 5) + want := "line one\nline two\nline three" + if got != want { + t.Errorf("lastNLines no-CR: got %q, want %q", got, want) + } +} + func TestIsPromptOnlyExcerpt(t *testing.T) { tests := []struct { name string @@ -149,8 +171,16 @@ func TestIsPromptOnlyExcerpt(t *testing.T) { {"prompt across multiple lines", "$\n\n%\n", true}, {"meaningful first line", "error: missing semicolon\n%", false}, {"meaningful last line", "%\nbuilding...", false}, - {"long word", "%cargo", false}, - {"text resembling prompt then content", "%%", false}, + // Suffix-rune classifier: "%cargo" ends in "o" → not prompt. + {"prompt rune as prefix of a word", "%cargo", false}, + // "%%" ends with "%" so the heuristic accepts it. Realistic shells + // don't emit this, so suppressing is the lesser evil. + {"repeated prompt rune", "%%", true}, + // OSC 0 window-title leak shape — must suppress. + {"hostname leak", "Artjoms_Stukans@EPCHZURW03: /Users/me/proj", true}, + // `ls` output should NOT be classified as prompt material. + {"ls output", "LICENSE\tcmd\tgo.sum", false}, + {"long line with prompt rune still emits", strings.Repeat("a", 250) + "%", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 2e38d1affd275f73a6c44cf1a498d57a5c8623d5 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 15:09:09 +0200 Subject: [PATCH 07/13] fix(daemon): raise Data value cap to 1 KiB for multi-line excerpts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combined PR's data.excerpt field (set by withExcerpt for MCP consumers) is typically a multi-line stripped excerpt of 200-500 bytes. The original 128-byte Data value cap from the broadcast-hardening PR was tuned for short metadata (exit_code, tool name) and would silently truncate the excerpt to a 1-2 line preview. Raise the per-Data-value cap to 1 KiB. The Message field still has the 4 KiB cap for the full excerpt, but agents reading data.excerpt now also get the realistic full content. Per-event ceiling becomes 4 KiB + N × 1 KiB; realistic events have 2-4 keys so total stays well under 10 KiB. Updated TestToPaneEventPayload_DataValueOverCapTruncates to use 4 KiB input (guaranteed to exceed any reasonable future tuning); the exact- boundary test already pins the cap edge precisely. --- .claude/CLAUDE.md | 2 +- internal/daemon/event.go | 10 ++++++---- internal/daemon/event_sizecap_test.go | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d9f10c3..06c32d3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -22,7 +22,7 @@ Client-daemon model: - `cmd/quild/` — Background daemon - `internal/config/` — TOML configuration (`Load` reads, `Save` writes atomically via `.tmp` + rename). `UIConfig.ShowDisclaimer` controls startup beta dialog - `internal/daemon/` — Session manager, message routing, event queue (`event.go` — bounded, mutex-protected, watcher pub/sub for MCP) -- `internal/ipc/` — Length-prefixed JSON protocol (4-byte big-endian uint32 + JSON). Each `Conn` owns a 64-slot send buffer (`sendCh`) drained by a dedicated `sendLoop` goroutine; `Send`/`sendFrame` are non-blocking. `Broadcast` marshals once and shares the cloned wire frame across N conns lock-free after snapshotting `s.conns`. A slow / wedged peer's `sendFrame` trips a CAS-guarded overflow that logs once + spawns `go c.Close()` (`ErrSendOverflow`); other clients never block. `sendLoop` enforces a 30 s `SetWriteDeadline` per frame as a belt-and-suspenders catch for kernel-buffer wedges. `Server.ConnCount()` exposes the live count for tests. Daemon-side per-event size caps (4 KiB Message, 128 B per Data value, front-truncated with `…[truncated]` marker, reserved `_quil_truncated` flag) live in `internal/daemon/event.go:toPaneEventPayload` +- `internal/ipc/` — Length-prefixed JSON protocol (4-byte big-endian uint32 + JSON). Each `Conn` owns a 64-slot send buffer (`sendCh`) drained by a dedicated `sendLoop` goroutine; `Send`/`sendFrame` are non-blocking. `Broadcast` marshals once and shares the cloned wire frame across N conns lock-free after snapshotting `s.conns`. A slow / wedged peer's `sendFrame` trips a CAS-guarded overflow that logs once + spawns `go c.Close()` (`ErrSendOverflow`); other clients never block. `sendLoop` enforces a 30 s `SetWriteDeadline` per frame as a belt-and-suspenders catch for kernel-buffer wedges. `Server.ConnCount()` exposes the live count for tests. Daemon-side per-event size caps (4 KiB Message, 1 KiB per Data value — fits a multi-line excerpt in `data.excerpt`, front-truncated with `…[truncated]` marker, reserved `_quil_truncated` flag) live in `internal/daemon/event.go:toPaneEventPayload` - `internal/persist/` — Atomic workspace/buffer persistence (JSON snapshots, binary ghost buffers) - `internal/pty/` — Cross-platform PTY (build tags: `linux || darwin || freebsd`, `windows`) - `internal/shellinit/` — Automatic OSC 7 + OSC 133 shell integration (embedded init scripts, `//go:embed`) diff --git a/internal/daemon/event.go b/internal/daemon/event.go index 6aae1c4..e661b3f 100644 --- a/internal/daemon/event.go +++ b/internal/daemon/event.go @@ -206,9 +206,11 @@ func (q *eventQueue) RemoveWatchersByConn(conn *ipc.Conn) { // Per-event wire-size caps. The earlier wedge incident happened with a // > 1 KiB box-drawing excerpt from an opencode splash screen flooding the -// IPC fan-out. 4 KiB per Message and 128 bytes per Data value give comfortable -// headroom for legitimate content (multi-line excerpts, command previews, -// error stacks) while keeping a runaway event source from bloating the wire. +// IPC fan-out. 4 KiB per Message and 1 KiB per Data value give comfortable +// headroom for legitimate content (multi-line excerpts in data.excerpt, +// command previews, hook payloads, error stacks) while keeping a runaway +// event source from bloating the wire. Per-event ceiling with N Data keys: +// 4 KiB + N × 1 KiB. Realistic event has 2-4 keys so total < 10 KiB. // // Truncation strategy: keeps the TAIL because PaneEvent.Message is used for // terminal excerpts (last visible lines = what the user sees) and idle @@ -224,7 +226,7 @@ func (q *eventQueue) RemoveWatchersByConn(conn *ipc.Conn) { // enforces this at compile time. const ( maxEventMessageBytes = 4 * 1024 - maxEventDataValueBytes = 128 + maxEventDataValueBytes = 1024 truncationMarker = "…[truncated]" ) diff --git a/internal/daemon/event_sizecap_test.go b/internal/daemon/event_sizecap_test.go index c969776..d7c9a43 100644 --- a/internal/daemon/event_sizecap_test.go +++ b/internal/daemon/event_sizecap_test.go @@ -44,7 +44,9 @@ func TestToPaneEventPayload_MessageOverCapTruncatesAndMarks(t *testing.T) { // dumping a full prompt or a stack trace). func TestToPaneEventPayload_DataValueOverCapTruncates(t *testing.T) { t.Parallel() - bigValue := strings.Repeat("y", 1024) + // 4 KiB — guaranteed to exceed the 1 KiB Data value cap regardless of + // future tuning. The exact-boundary test below pins the cap edge. + bigValue := strings.Repeat("y", 4*1024) ev := PaneEvent{ ID: "evt-1", Title: "tool_run", From 57aafc1ad122c94e5d7d6737dbd10f52eec2592b Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 15:38:35 +0200 Subject: [PATCH 08/13] fix(notifications): address review findings on PR #41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High (3): - H1 isPromptLikeLine over-suppression. "build complete: 100%" used to classify as prompt-like because the suffix rune match accepted any trailing prompt char. Now the rune must EITHER be the bare line OR be preceded by whitespace — so "% " matches, "user@host %" matches, but "100%" and "x$" do not. Two new test rows pin the regression. - H2 idle-checker cooldown leak. checkIdlePanes was setting LastIdleEventAt = now BEFORE the prompt-only suppression decision, so a suppressed event still consumed the 30 s cooldown — a single spurious suppression caused 30 s of silence even if real activity followed. On suppression we now roll back LastIdleEventAt to the zero value; IdleNotified stays true so the next tick doesn't re-evaluate the same idle state (flushPaneOutput resets it on the next PTY byte). - H3 sidebar cursor drift on aggregation. NotificationCenter.AddEvent on a same-ID match moves the card to position 0; the cursor stayed at its old index, silently jumping the user's selection onto a different card. Now we snapshot the cursor's event ID BEFORE the move-to-front and re-locate the cursor onto the same logical event afterward. Fresh prepends still leave the cursor at index 0 (legacy "cursor 0 = newest" semantics) — only the aggregation path chases. Medium (4): - CLAUDE.md L86: MCP tool count was "17"; now "18" including dismiss_notifications. mcp_tools.go implementation count was "15"; now "18". The MCP tool list reflects all three notification additions (excerpt-carrying get_notifications, since_timestamp on watch_*, dismiss_notifications). - docs/configuration.md: full [notification] section added documenting sidebar_width and max_events. Bindable actions table gains mute_pane, notification_toggle, notification_focus, go_back, notes_toggle rows — all of which were missing. - daemon.go idle-decision debug log was emitting up to 200 bytes of raw excerpt content. Per observability-and-logging.md "Never log user-provided content" rule, even debug must not echo PTY bytes (panes routinely contain API keys, passwords from REPLs, .env on cat). Replaced with structural metadata: excerpt_bytes + excerpt_lines. Sufficient to diagnose suppression decisions without the secret-leak risk. - internal/tui/dialog.go shortcuts list: added Alt+M mute, Alt+N notification toggle, F3 sidebar focus, Alt+Backspace pane history, Alt+E notes — all previously undiscoverable from F1. - plugin/registry.go [[notification_handlers]] deprecation warning was per-load — a MsgReloadPlugins call would re-fire the warning for every stale plugin every time. Now one-shot per plugin name per daemon lifetime via a sync.Map sentinel. Daemon restart re-enables. Low (6): - Replaced developer's real OS username (Artjoms_Stukans) and corporate-shaped hostname (EPCHZURW03) in 3 test fixtures with synthetic user_name@host01 — the regex behavior is identical. - Added t.Parallel() to all 7 net-new test files in this PR (44 test functions). The IPC tests were already parallel; daemon and tui tests were the inconsistency. Coverage gaps (3): - TestEventQueue_Push_CorruptCountResetsToBaseline pins the strconv.Atoi error branch in the aggregation count parser (corrupt Data["count"] resets to baseline of 1 instead of panicking). - TestIsPromptLikeLine_EmptyString_DirectCall directly invokes isPromptLikeLine("") (previously only reached transitively through isPromptOnlyExcerpt's blank-line filter). - TestLoadPluginTOML_NotificationHandlersDeprecated drops a legacy TOML fixture with [[notification_handlers]] in a t.TempDir() and asserts the load succeeds + NotificationHandlers slice still populates (back-compat preserved even after the deprecation). - TestNotificationCenter_AddEvent_CursorFollowsLogicalEventOnAggregation is the H3 invariant pin: a cursor on a non-aggregated event must follow that event's new index after move-to-front of a different event. All green under go test ./... and go test -race ./... --- .claude/CLAUDE.md | 4 +- docs/configuration.md | 22 +++++ internal/daemon/daemon.go | 80 +++++++++++++++---- internal/daemon/event_aggregate_test.go | 32 ++++++++ .../daemon/event_excerpt_followup_test.go | 50 +++++++++--- internal/daemon/event_excerpt_test.go | 10 +++ internal/daemon/event_findsince_test.go | 5 ++ internal/plugin/plugin_test.go | 45 +++++++++++ internal/plugin/registry.go | 19 ++++- internal/tui/dialog.go | 4 + internal/tui/notification.go | 41 +++++++++- internal/tui/notification_active_pane_test.go | 6 ++ internal/tui/notification_aggregate_test.go | 42 ++++++++++ internal/tui/notification_excerpt_test.go | 4 + 14 files changed, 329 insertions(+), 35 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 06c32d3..3f1cd93 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -83,11 +83,11 @@ Architecture: thin bridge between MCP JSON-RPC (stdio) and daemon IPC (socket). MCP SDK: `github.com/modelcontextprotocol/go-sdk` (official SDK, v1.4+). Typed tool handlers with struct-based input schemas. -17 MCP tools: `list_panes`, `read_pane_output` (ANSI-stripped), `send_to_pane`, `get_pane_status`, `create_pane`, `send_keys` (named key sequences), `restart_pane`, `screenshot_pane` (VT-emulated text screenshot), `switch_tab`, `list_tabs`, `destroy_pane`, `set_active_pane` (TUI cooperation), `close_tui` (TUI cooperation), `get_notifications` (non-blocking), `watch_notifications` (blocking, replaces polling), `get_memory_report` (per-tab totals + Go-heap + PTY RSS), `get_pane_memory` (single pane detail). +18 MCP tools: `list_panes`, `read_pane_output` (ANSI-stripped), `send_to_pane`, `get_pane_status`, `create_pane`, `send_keys` (named key sequences), `restart_pane`, `screenshot_pane` (VT-emulated text screenshot), `switch_tab`, `list_tabs`, `destroy_pane`, `set_active_pane` (TUI cooperation), `close_tui` (TUI cooperation), `get_notifications` (non-blocking; carries `data.excerpt` with the triggering lines), `watch_notifications` (blocking, replaces polling; optional `since_timestamp` closes the race-on-registration window), `dismiss_notifications` (ack handled events from the agent side), `get_memory_report` (per-tab totals + Go-heap + PTY RSS), `get_pane_memory` (single pane detail). IPC request-response: `Message.ID` field (omitempty, backward compatible) correlates requests with responses. Daemon responds to the requesting connection when `ID` is set, broadcasts when empty. -Key files: `cmd/quil/mcp.go` (bridge + daemon connection), `cmd/quil/mcp_tools.go` (15 tool implementations), `cmd/quil/mcp_keys.go` (key name → escape sequence map), `cmd/quil/mcp_log.go` (per-pane interaction logging + two-layer redaction). +Key files: `cmd/quil/mcp.go` (bridge + daemon connection), `cmd/quil/mcp_tools.go` (18 tool implementations), `cmd/quil/mcp_keys.go` (key name → escape sequence map), `cmd/quil/mcp_log.go` (per-pane interaction logging + two-layer redaction). AI tool configuration: ```json diff --git a/docs/configuration.md b/docs/configuration.md index 9fc8c58..448c6ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,7 @@ Quil reads `~/.quil/config.toml` (or `$QUIL_HOME/config.toml` when `QUIL_HOME` i - [`[logging]`](#logging) - [`[ui]`](#ui) - [`[mcp]`](#mcp) +- [`[notification]`](#notification) - [`[keybindings]`](#keybindings) - [Per-plugin instances](#per-plugin-instances) - [How edits get persisted](#how-edits-get-persisted) @@ -50,6 +51,10 @@ show_disclaimer = true # beta disclaimer on startup [mcp] highlight_duration = "10s" # border flash duration when AI touches a pane +[notification] +sidebar_width = 30 # columns reserved for the notification sidebar +max_events = 200 # ring-buffer cap (per daemon, both sidebar and MCP) + [keybindings] quit = "ctrl+q" new_tab = "ctrl+t" @@ -70,6 +75,11 @@ scroll_page_up = "alt+pgup" scroll_page_down = "alt+pgdown" paste = "ctrl+v" focus_pane = "ctrl+e" +notification_toggle = "alt+n" # show / focus / hide the notification sidebar +notification_focus = "f3" # jump focus to the sidebar (alt path when alt+n misbehaves) +mute_pane = "alt+m" # toggle notification mute for the active pane +go_back = "alt+backspace" # pane history back (after jumping via sidebar Enter) +notes_toggle = "alt+e" # toggle pane notes editor ``` ## `[daemon]` @@ -113,6 +123,13 @@ The "ghost buffer" is the rendered preview Quil shows immediately on reconnect, |---|---|---|---| | `highlight_duration` | duration | `"10s"` | When the AI interacts with a pane via MCP, its border flashes orange for this duration. `"0s"` disables. See [MCP visual indicator](mcp.md#visual-mcp-activity-indicator). | +## `[notification]` + +| Key | Type | Default | What it does | +|---|---|---|---| +| `sidebar_width` | int | `30` | Columns reserved for the notification sidebar when toggled (`Alt+N`). Reducing this gives more room to panes; values below ~25 truncate event titles and excerpts heavily. | +| `max_events` | int | `200` | Ring-buffer cap for the daemon's notification queue. The sidebar and MCP `get_notifications` both read from this queue. Each event is bounded to ≤ 4 KiB `Message` + ≤ 1 KiB per `Data` value (`_quil_truncated` flag set when truncated). | + ## `[keybindings]` Every binding accepts a Bubble Tea key string. Common forms: @@ -142,6 +159,11 @@ Multiple modifiers stack with `+` (no spaces). Mouse buttons are not bindable he | `scroll_page_up` / `scroll_page_down` | `alt+pgup` / `alt+pgdown` | Pane scrollback | | `paste` | `ctrl+v` | Paste from clipboard (text or image) | | `focus_pane` | `ctrl+e` | Toggle focus mode | +| `notification_toggle` | `alt+n` | Cycle the notification sidebar: hidden → visible → visible+focused → hidden | +| `notification_focus` | `f3` | Jump focus to the sidebar (alt path when `alt+n` is intercepted by the terminal) | +| `mute_pane` | `alt+m` | Toggle notification mute on the active pane. Muted panes show `[muted]` on their border and never fire idle / bell / process-exit / hook events. Persisted in `workspace.json` so mute survives daemon restart. | +| `go_back` | `alt+backspace` | Pane history back — return to the pane you were on before the sidebar's `Enter` jump | +| `notes_toggle` | `alt+e` | Open / close the per-pane notes editor | ## Per-plugin instances diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c680984..20f603f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1822,17 +1822,25 @@ func (d *Daemon) checkIdlePanes() { // approval"), the regex saw something meaningful in the excerpt // even though the surface chars collapse to a prompt rune. suppress := title == "Output idle" && isPromptOnlyExcerpt(excerpt) - // Debug visibility into what excerpts the idle checker sees in - // the wild — helps diagnose why suppression fires (or doesn't) - // for a given shell setup. Truncate the excerpt to keep the - // log line bounded. - logExcerpt := excerpt - if len(logExcerpt) > 200 { - logExcerpt = logExcerpt[:200] + "..." - } - logger.Debug("idle decision: pane=%s type=%s title=%q suppress=%v excerpt=%q", - pane.ID, pane.Type, title, suppress, logExcerpt) + // Diagnostic: structural metadata only, NEVER the raw excerpt + // content. Terminal panes can contain secrets (`echo $API_KEY`, + // `mysql -p…`, `cat .env`) — even at debug level we must not log + // user-provided content per observability-and-logging.md. Length + // + line count + line-end class are sufficient to diagnose + // suppression decisions (the OSC 0 leak case shows up as + // excerpt_lines=1 line_end_class=text, normal shell prompts as + // line_end_class=prompt_rune). + logger.Debug("idle decision: pane=%s type=%s title=%q suppress=%v excerpt_bytes=%d excerpt_lines=%d", + pane.ID, pane.Type, title, suppress, len(excerpt), countNonEmptyLines(excerpt)) if suppress { + // Roll back the cooldown bookkeeping: we DID NOT emit, so + // the next real activity should fire promptly instead of + // waiting out a fake 30 s cooldown. IdleNotified stays true + // — flushPaneOutput resets it on the next byte from the PTY, + // so we won't re-evaluate the same idle state every tick. + pane.PluginMu.Lock() + pane.LastIdleEventAt = time.Time{} + pane.PluginMu.Unlock() continue } d.emitEvent(withExcerpt(PaneEvent{ @@ -1987,7 +1995,7 @@ var promptRunes = map[string]bool{ "»": true, // bash-it powerline } -// hostnameLikeRe matches user@host patterns (e.g. "Artjoms_Stukans@HOSTNAME") +// hostnameLikeRe matches user@host patterns (e.g. "user_name@host01") // that leak into excerpts from OSC 0 window-title sequences when ansi.Strip // or upstream emulators bail on an embedded CR. These leaks are // indistinguishable from "the pane is at a prompt" because the underlying @@ -2029,10 +2037,19 @@ func isPromptOnlyExcerpt(excerpt string) bool { // isPromptOnlyExcerpt. See that function's docs for the classification rules. // // Specifically, a line is "prompt-like" when, after trimming trailing -// whitespace, it ENDS with a recognised prompt rune (covers `%`, `user@host -// % `, `~/repo $ `, etc) OR it matches the user@host pattern that OSC 0 -// window-title leaks produce. Long lines (> 200 chars) are presumed to be -// command output regardless of trailing chars — real prompts are short. +// whitespace, it is: +// +// - the bare prompt rune by itself (e.g. "%"), OR +// - a recognised prompt rune as the trailing token, preceded by whitespace +// (e.g. "user@host %", "~/repo $ ", "❯ "), OR +// - matches the user@host pattern that OSC 0 window-title leaks produce. +// +// The space-before-rune requirement is what distinguishes a real prompt +// from a number-with-percent (`"build complete: 100%"`) or a literal text +// ending in a prompt-like rune (`"x$"`). Without it the classifier would +// suppress legitimate command output that happens to end in a prompt rune. +// Long lines (> 200 chars) are presumed to be command output regardless of +// trailing chars — real prompts are short. func isPromptLikeLine(line string) bool { if line == "" { return true @@ -2045,9 +2062,24 @@ func isPromptLikeLine(line string) bool { } trimmed := strings.TrimRight(line, " \t") for r := range promptRunes { - if strings.HasSuffix(trimmed, r) { + if !strings.HasSuffix(trimmed, r) { + continue + } + // Bare prompt rune (e.g. trimmed == "%"). + if trimmed == r { return true } + // Prompt rune preceded by whitespace (e.g. "user@host %"). The byte + // immediately before the rune must be a space or tab — that's what + // makes it a standalone prompt terminator instead of part of a + // word like "100%" or "x$". + runeStart := len(trimmed) - len(r) + if runeStart > 0 { + prev := trimmed[runeStart-1] + if prev == ' ' || prev == '\t' { + return true + } + } } if hostnameLikeRe.MatchString(line) { return true @@ -2055,6 +2087,22 @@ func isPromptLikeLine(line string) bool { return false } +// countNonEmptyLines returns the number of non-blank lines in s. Used for +// structural diagnostics in the idle-decision debug log so we can surface +// excerpt shape ("N lines, M bytes") without echoing the raw content. +func countNonEmptyLines(s string) int { + if s == "" { + return 0 + } + n := 0 + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} + // withExcerpt populates PaneEvent.Message and Data["excerpt"] from the pane's // tail output. Idempotent: callers that already extracted the excerpt (e.g. // the idle checker, which needs it for regex matching) can pass excerpt diff --git a/internal/daemon/event_aggregate_test.go b/internal/daemon/event_aggregate_test.go index c7b0097..9d38c0e 100644 --- a/internal/daemon/event_aggregate_test.go +++ b/internal/daemon/event_aggregate_test.go @@ -9,6 +9,7 @@ import ( // "two pane-a39ad0c Waiting for input cards" issue is now collapsed into one // card with a count badge. func TestEventQueue_Push_AggregatesSameTitleSamePane(t *testing.T) { + t.Parallel() q := newEventQueue(10) t0 := time.Unix(0, 0).Add(1 * time.Second) q.Push(PaneEvent{ID: "first-id", PaneID: "p1", Title: "Waiting for input", Timestamp: t0}) @@ -31,6 +32,7 @@ func TestEventQueue_Push_AggregatesSameTitleSamePane(t *testing.T) { } func TestEventQueue_Push_AggregationKeepsCountingAcrossManyHits(t *testing.T) { + t.Parallel() q := newEventQueue(10) at := time.Unix(0, 0).Add(1 * time.Second) for i := 0; i < 5; i++ { @@ -51,6 +53,7 @@ func TestEventQueue_Push_AggregationKeepsCountingAcrossManyHits(t *testing.T) { } func TestEventQueue_Push_DifferentTitleNotAggregated(t *testing.T) { + t.Parallel() q := newEventQueue(10) at := time.Unix(0, 0).Add(1 * time.Second) q.Push(PaneEvent{ID: "a", PaneID: "p1", Title: "Output idle", Timestamp: at}) @@ -62,6 +65,7 @@ func TestEventQueue_Push_DifferentTitleNotAggregated(t *testing.T) { } func TestEventQueue_Push_DifferentPaneNotAggregated(t *testing.T) { + t.Parallel() q := newEventQueue(10) at := time.Unix(0, 0).Add(1 * time.Second) q.Push(PaneEvent{ID: "a", PaneID: "p1", Title: "Output idle", Timestamp: at}) @@ -73,6 +77,7 @@ func TestEventQueue_Push_DifferentPaneNotAggregated(t *testing.T) { } func TestEventQueue_Push_EmptyPaneIDNeverAggregates(t *testing.T) { + t.Parallel() // Daemon-level events without a pane source must remain distinct so // they're never accidentally collapsed. q := newEventQueue(10) @@ -85,7 +90,34 @@ func TestEventQueue_Push_EmptyPaneIDNeverAggregates(t *testing.T) { } } +// TestEventQueue_Push_CorruptCountResetsToBaseline guards the safety +// fallback in Push: when an existing event's Data["count"] is non-numeric +// (corrupted by a misbehaving emitter, a future schema change, etc.), the +// `strconv.Atoi` error branch defaults count to 1 so aggregation continues +// at "2" instead of panicking or producing nonsense. +func TestEventQueue_Push_CorruptCountResetsToBaseline(t *testing.T) { + t.Parallel() + q := newEventQueue(10) + at := time.Unix(0, 0).Add(1 * time.Second) + q.Push(PaneEvent{ + ID: "first", PaneID: "p1", Title: "x", Timestamp: at, + Data: map[string]string{"count": "garbage-not-a-number"}, + }) + q.Push(PaneEvent{ + ID: "second", PaneID: "p1", Title: "x", Timestamp: at.Add(time.Second), + }) + + events := q.Events() + if len(events) != 1 { + t.Fatalf("aggregation: got %d events, want 1", len(events)) + } + if got := events[0].Data["count"]; got != "2" { + t.Errorf("corrupt count must reset to 1 + 1 = 2; got %q", got) + } +} + func TestEventQueue_Push_AggregationMovesToFront(t *testing.T) { + t.Parallel() // Insert: A(p1), B(p2), then A again with same (p1, Output idle). The // repeat must end up at position 0, not at its old position. q := newEventQueue(10) diff --git a/internal/daemon/event_excerpt_followup_test.go b/internal/daemon/event_excerpt_followup_test.go index c651e98..c027185 100644 --- a/internal/daemon/event_excerpt_followup_test.go +++ b/internal/daemon/event_excerpt_followup_test.go @@ -13,6 +13,7 @@ import ( // text. Reproduces the field-observed garbage where excerpts contained // "2;30;30;30m" (fragment of \x1b[2;30;30;30m) at the start. func TestTrimToNewlineSafe_AdvancesPastPartialANSI(t *testing.T) { + t.Parallel() // Craft a buffer where the maxTail slice would otherwise begin inside // an ANSI sequence. Layout: \x1b[31mRED\n prefix := strings.Repeat("x", 100) @@ -35,6 +36,7 @@ func TestTrimToNewlineSafe_AdvancesPastPartialANSI(t *testing.T) { } func TestTrimToNewlineSafe_NoTrimWhenSmaller(t *testing.T) { + t.Parallel() raw := []byte("short data") got := trimToNewlineSafe(raw, 4096) if string(got) != "short data" { @@ -43,6 +45,7 @@ func TestTrimToNewlineSafe_NoTrimWhenSmaller(t *testing.T) { } func TestTrimToNewlineSafe_NoNewlineFallback(t *testing.T) { + t.Parallel() // Pathological case: no newline AND no ESC in the bounded scan // window. We accept some leading garbage rather than dropping the // whole window — small loss compared to silently emitting nothing. @@ -59,6 +62,7 @@ func TestTrimToNewlineSafe_NoNewlineFallback(t *testing.T) { // in the slice. The hardened version also recognises ESC bytes as a clean // boundary because ansi.Strip handles full sequences from there. func TestTrimToNewlineSafe_SeeksESCWhenNoNewline(t *testing.T) { + t.Parallel() // 100 bytes of CSI parameter garbage (no newline, no ESC), then an ESC // starting a fresh sequence + real content. prefix := strings.Repeat("a", 100) @@ -83,6 +87,7 @@ func TestTrimToNewlineSafe_SeeksESCWhenNoNewline(t *testing.T) { // TestTrimToNewlineSafe_NewlineBeatsESCWhenFirst confirms whichever boundary // comes first wins (newline before ESC in this case). func TestTrimToNewlineSafe_NewlineBeatsESCWhenFirst(t *testing.T) { + t.Parallel() prefix := strings.Repeat("a", 100) tail := "garbage\nline-after-newline\x1b[31mansi-later" raw := []byte(prefix + tail) @@ -102,6 +107,7 @@ func TestTrimToNewlineSafe_NewlineBeatsESCWhenFirst(t *testing.T) { // TestTrimToNewlineSafe_ScanBoundary asserts that the scan stops at maxScan // bytes — far-away boundaries don't drag the whole window forward. func TestTrimToNewlineSafe_ScanBoundary(t *testing.T) { + t.Parallel() // 2 KiB of plain text (no boundary), then a newline. Scan window is // 512 bytes, so the seek must not find the newline and must return // the un-advanced slice. @@ -118,6 +124,7 @@ func TestTrimToNewlineSafe_ScanBoundary(t *testing.T) { } func TestPaneOutputExcerpt_FieldReproducesAndFixesANSILeak(t *testing.T) { + t.Parallel() // Reproduce the screenshot bug end-to-end. The ring buffer holds a // stream of bytes that exceeds 4096; the trailing slice would have // previously begun inside the CSI parameters and leaked them. @@ -133,19 +140,34 @@ func TestPaneOutputExcerpt_FieldReproducesAndFixesANSILeak(t *testing.T) { } // TestLastNLines_AppliesCarriageReturnReset proves that the field-observed -// "% ... \r \r\rArtjoms_Stukans@HOSTNAME" excerpt collapses to just -// the post-CR content (what the terminal would actually display), not the -// prompt rune that was immediately overwritten. +// "% ... \r \r\ruser@host" excerpt collapses to just the post-CR +// content (what the terminal would actually display), not the prompt rune +// that was immediately overwritten. func TestLastNLines_AppliesCarriageReturnReset(t *testing.T) { - in := "% \r \r\rArtjoms_Stukans@EPCHZURW03: /path" + t.Parallel() + in := "% \r \r\ruser_name@host01: /path" got := lastNLines(in, 5) - want := "Artjoms_Stukans@EPCHZURW03: /path" + want := "user_name@host01: /path" if got != want { t.Errorf("lastNLines CR-reset: got %q, want %q", got, want) } } +// TestIsPromptLikeLine_EmptyString_DirectCall pins the explicit guard at the +// top of isPromptLikeLine. The function returns true for an empty string so +// that multi-line prompt-only excerpts with blank lines between prompts +// continue to classify correctly. The isPromptOnlyExcerpt level filters +// blank lines before dispatch, so this branch only matters if a future +// caller invokes isPromptLikeLine directly with "". +func TestIsPromptLikeLine_EmptyString_DirectCall(t *testing.T) { + t.Parallel() + if !isPromptLikeLine("") { + t.Errorf("isPromptLikeLine(\"\") = false, want true (empty lines are vacuously prompt-like)") + } +} + func TestLastNLines_NoCarriageReturnUntouched(t *testing.T) { + t.Parallel() in := "line one\nline two\nline three" got := lastNLines(in, 5) want := "line one\nline two\nline three" @@ -155,6 +177,7 @@ func TestLastNLines_NoCarriageReturnUntouched(t *testing.T) { } func TestIsPromptOnlyExcerpt(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -173,11 +196,20 @@ func TestIsPromptOnlyExcerpt(t *testing.T) { {"meaningful last line", "%\nbuilding...", false}, // Suffix-rune classifier: "%cargo" ends in "o" → not prompt. {"prompt rune as prefix of a word", "%cargo", false}, - // "%%" ends with "%" so the heuristic accepts it. Realistic shells - // don't emit this, so suppressing is the lesser evil. - {"repeated prompt rune", "%%", true}, + // "%%" ends with "%" but the char before is also "%", not whitespace + // — H1 fix rejects it. Realistic shells don't emit "%%" as a prompt. + {"repeated prompt rune", "%%", false}, + // H1 regression: percentage-of-number must NOT be classified as + // prompt-like. The `%` is preceded by a digit, not whitespace. + {"percentage of number", "build complete: 100%", false}, + // H1 sanity check: prompt rune preceded by a space IS a real prompt. + {"prompt rune after path", "~/projects/quil %", true}, // OSC 0 window-title leak shape — must suppress. - {"hostname leak", "Artjoms_Stukans@EPCHZURW03: /Users/me/proj", true}, + {"hostname leak", "user_name@host01: /Users/me/proj", true}, + // Empty / whitespace-only lines must be treated as prompt-like so + // "$\n\n%" (prompt-blank-prompt) still classifies as prompt-only. + {"empty line classified as prompt-like", "", false}, // empty EXCERPT is false (early return) + {"whitespace-only excerpt", " \n\t\n", false}, // `ls` output should NOT be classified as prompt material. {"ls output", "LICENSE\tcmd\tgo.sum", false}, {"long line with prompt rune still emits", strings.Repeat("a", 250) + "%", false}, diff --git a/internal/daemon/event_excerpt_test.go b/internal/daemon/event_excerpt_test.go index 7d4cb30..82cb9e2 100644 --- a/internal/daemon/event_excerpt_test.go +++ b/internal/daemon/event_excerpt_test.go @@ -8,6 +8,7 @@ import ( ) func TestPaneOutputExcerpt_LastNLines(t *testing.T) { + t.Parallel() pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} pane.OutputBuf.Write([]byte("first\nsecond\nthird\nfourth\n")) @@ -19,6 +20,7 @@ func TestPaneOutputExcerpt_LastNLines(t *testing.T) { } func TestPaneOutputExcerpt_SkipsEmptyLines(t *testing.T) { + t.Parallel() pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} pane.OutputBuf.Write([]byte("alpha\n\n\nbeta\n\n")) @@ -30,6 +32,7 @@ func TestPaneOutputExcerpt_SkipsEmptyLines(t *testing.T) { } func TestPaneOutputExcerpt_StripsANSI(t *testing.T) { + t.Parallel() pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} // Red "error" + reset, then a plain line. pane.OutputBuf.Write([]byte("\x1b[31merror\x1b[0m\nokay\n")) @@ -42,6 +45,7 @@ func TestPaneOutputExcerpt_StripsANSI(t *testing.T) { } func TestPaneOutputExcerpt_EmptyBuffer(t *testing.T) { + t.Parallel() pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(4096)} if got := paneOutputExcerpt(pane, 3); got != "" { t.Errorf("paneOutputExcerpt empty: got %q, want %q", got, "") @@ -49,6 +53,7 @@ func TestPaneOutputExcerpt_EmptyBuffer(t *testing.T) { } func TestPaneOutputExcerpt_NilBuffer(t *testing.T) { + t.Parallel() pane := &Pane{} if got := paneOutputExcerpt(pane, 3); got != "" { t.Errorf("paneOutputExcerpt nil buf: got %q, want %q", got, "") @@ -56,12 +61,14 @@ func TestPaneOutputExcerpt_NilBuffer(t *testing.T) { } func TestPaneOutputExcerpt_NilPane(t *testing.T) { + t.Parallel() if got := paneOutputExcerpt(nil, 3); got != "" { t.Errorf("paneOutputExcerpt nil pane: got %q, want %q", got, "") } } func TestPaneOutputExcerpt_LargeBufferTrailingCap(t *testing.T) { + t.Parallel() pane := &Pane{OutputBuf: ringbuf.NewRingBuffer(16384)} // Build > 4 KiB of "line N\n" — only the trailing window should be read. var sb strings.Builder @@ -80,6 +87,7 @@ func TestPaneOutputExcerpt_LargeBufferTrailingCap(t *testing.T) { } func TestWithExcerpt_PopulatesMessageAndData(t *testing.T) { + t.Parallel() e := PaneEvent{ ID: "evt-1", Type: "process_exit", @@ -99,6 +107,7 @@ func TestWithExcerpt_PopulatesMessageAndData(t *testing.T) { } func TestWithExcerpt_EmptyExcerptIsNoop(t *testing.T) { + t.Parallel() e := PaneEvent{ ID: "evt-1", Data: map[string]string{"exit_code": "0"}, @@ -116,6 +125,7 @@ func TestWithExcerpt_EmptyExcerptIsNoop(t *testing.T) { } func TestWithExcerpt_NilData_CreatedOnDemand(t *testing.T) { + t.Parallel() e := PaneEvent{ID: "evt-1"} got := withExcerpt(e, "context") if got.Data == nil { diff --git a/internal/daemon/event_findsince_test.go b/internal/daemon/event_findsince_test.go index 9f39a4a..d25a570 100644 --- a/internal/daemon/event_findsince_test.go +++ b/internal/daemon/event_findsince_test.go @@ -12,6 +12,7 @@ import ( // because agents handle events in order — jumping straight to the newest // would skip intermediate state changes. func TestEventQueue_FindSince_ReturnsOldestNewerEvent(t *testing.T) { + t.Parallel() q := newEventQueue(10) t0 := time.Unix(0, 0).Add(1000 * time.Millisecond) q.Push(PaneEvent{ID: "old", PaneID: "p1", Timestamp: t0}) @@ -28,6 +29,7 @@ func TestEventQueue_FindSince_ReturnsOldestNewerEvent(t *testing.T) { } func TestEventQueue_FindSince_ExclusiveOnTimestamp(t *testing.T) { + t.Parallel() q := newEventQueue(10) at := time.Unix(0, 0).Add(2000 * time.Millisecond) q.Push(PaneEvent{ID: "exact-match", PaneID: "p1", Timestamp: at}) @@ -39,6 +41,7 @@ func TestEventQueue_FindSince_ExclusiveOnTimestamp(t *testing.T) { } func TestEventQueue_FindSince_RespectsPaneFilter(t *testing.T) { + t.Parallel() q := newEventQueue(10) t0 := time.Unix(0, 0).Add(1000 * time.Millisecond) q.Push(PaneEvent{ID: "a", PaneID: "pane-A", Timestamp: t0.Add(10 * time.Millisecond)}) @@ -56,6 +59,7 @@ func TestEventQueue_FindSince_RespectsPaneFilter(t *testing.T) { } func TestEventQueue_FindSince_NoMatchReturnsNil(t *testing.T) { + t.Parallel() q := newEventQueue(10) at := time.Unix(0, 0).Add(500 * time.Millisecond) q.Push(PaneEvent{ID: "old", PaneID: "p1", Timestamp: at}) @@ -67,6 +71,7 @@ func TestEventQueue_FindSince_NoMatchReturnsNil(t *testing.T) { } func TestEventQueue_FindSince_EmptyQueue(t *testing.T) { + t.Parallel() q := newEventQueue(10) if got := q.FindSince(0, nil); got != nil { t.Errorf("empty queue: got %q, want nil", got.ID) diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index c13bf36..b2cbcd7 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -59,6 +59,51 @@ func TestRegistryWithDefaults(t *testing.T) { } } +// TestLoadPluginTOML_NotificationHandlersDeprecated verifies that legacy TOML +// containing the deprecated [[notification_handlers]] section loads +// successfully (back-compat preserved) and still populates the +// NotificationHandlers slice — even though the daemon no longer evaluates +// them. The deprecation warning fires at most once per plugin name per +// daemon lifetime (one-shot sentinel) to avoid log spam during reload. +func TestLoadPluginTOML_NotificationHandlersDeprecated(t *testing.T) { + dir := t.TempDir() + tomlPath := filepath.Join(dir, "legacy.toml") + const legacyTOML = ` +[plugin] +name = "legacy" +display_name = "Legacy" +category = "tools" + +[command] +cmd = "echo" +detect = "echo --version" + +[[notification_handlers]] +pattern = '(?i)error|fail' +title = "Old handler" +severity = "warning" +` + if err := os.WriteFile(tomlPath, []byte(legacyTOML), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + r := NewRegistry() + if err := r.LoadFromDir(dir); err != nil { + t.Fatalf("LoadFromDir with [[notification_handlers]] must not error; got %v", err) + } + + p := r.Get("legacy") + if p == nil { + t.Fatal("legacy plugin not loaded — deprecation warning must not block back-compat") + } + if len(p.NotificationHandlers) != 1 { + t.Errorf("NotificationHandlers populated for back-compat; got %d, want 1", len(p.NotificationHandlers)) + } + if p.NotificationHandlers[0].Title != "Old handler" { + t.Errorf("NotificationHandler title: got %q, want %q", p.NotificationHandlers[0].Title, "Old handler") + } +} + // TestLoadPluginTOML_ClaudeCodeSetup_ParsesPromptsCWDAndPermissionToggles verifies // the prompts_cwd and [[command.toggles]] opt-ins parse correctly from the // embedded claude-code default TOML, including the mutually-exclusive diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index d6ad4af..06170a9 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -19,6 +19,14 @@ type Registry struct { mu sync.RWMutex } +// deprecationWarned remembers which plugins have already emitted the +// [[notification_handlers]] deprecation warning during this daemon's +// lifetime. Without this, a `reload_plugins` IPC call would re-fire the +// warning every time the daemon walks the same TOML — log spam during +// development. Cleared only by daemon restart, which is the natural +// "I want to re-see the migration prompt" trigger. +var deprecationWarned sync.Map // key: plugin name (string), value: struct{} + // NewRegistry creates a registry pre-loaded with built-in plugins. func NewRegistry() *Registry { r := &Registry{ @@ -455,11 +463,14 @@ func loadPluginTOML(path string) (*PanePlugin, error) { // proved too noisy (every PTY chunk triggered a scan) and was replaced by // the lighter [[idle_handlers]] that runs only when a pane goes idle. // MatchNotification still exists for back-compat with TOMLs in the wild - // but the daemon never calls it. Warn once per stale plugin so authors - // notice and migrate instead of silently editing dead config. + // but the daemon never calls it. Warn once per stale plugin per daemon + // lifetime so authors notice and migrate; subsequent reloads (e.g. + // MsgReloadPlugins) stay silent to avoid log spam during plugin editing. if len(tp.NotificationHandlers) > 0 { - log.Printf("plugin %q: [[notification_handlers]] is deprecated and no longer evaluated; "+ - "migrate to [[idle_handlers]] (same fields: pattern, title, severity)", tp.Plugin.Name) + if _, already := deprecationWarned.LoadOrStore(tp.Plugin.Name, struct{}{}); !already { + log.Printf("plugin %q: [[notification_handlers]] is deprecated and no longer evaluated; "+ + "migrate to [[idle_handlers]] (same fields: pattern, title, severity)", tp.Plugin.Name) + } } for _, nh := range tp.NotificationHandlers { p.NotificationHandlers = append(p.NotificationHandlers, NotificationHandler{ diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go index 9b320f4..dc05ba4 100644 --- a/internal/tui/dialog.go +++ b/internal/tui/dialog.go @@ -278,6 +278,10 @@ func shortcutsList(m *Model) []struct{ key, desc string } { {kbDisplay(kb.Paste), "Paste clipboard"}, {kbDisplay(kb.FocusPane), "Toggle focus mode"}, {kbDisplay(kb.NotesToggle), "Toggle pane notes"}, + {kbDisplay(kb.MutePane), "Mute / unmute pane notifications"}, + {kbDisplay(kb.NotificationToggle), "Toggle notification sidebar"}, + {kbDisplay(kb.NotificationFocus), "Focus notification sidebar"}, + {kbDisplay(kb.GoBack), "Pane history back"}, {"Ctrl+N", "New typed pane"}, {"Alt+1..9", "Switch to tab N"}, {"F1", "Help / About"}, diff --git a/internal/tui/notification.go b/internal/tui/notification.go index 84e5d45..fc15b5b 100644 --- a/internal/tui/notification.go +++ b/internal/tui/notification.go @@ -37,18 +37,51 @@ func NewNotificationCenter(width, maxEvents int) *NotificationCenter { // (PaneID, Title) event reuses the prior event's ID and bumps Data["count"]. // Without the move-to-front the sidebar would silently drop bumps and the // user would never see the ×N count grow. +// +// Cursor invariant: the cursor follows the LOGICAL event the user is on, +// not the index. If the move-to-front shifts other events past the cursor's +// position, we rewrite cursor to point at the event with the same ID it had +// before — so a user staring at "claude-code (×3)" does not silently jump to +// a different card when "claude-code (×4)" arrives. func (nc *NotificationCenter) AddEvent(e ipc.PaneEventPayload) { for i, existing := range nc.events { - if existing.ID == e.ID { - nc.events = append(nc.events[:i], nc.events[i+1:]...) - nc.events = append([]ipc.PaneEventPayload{e}, nc.events...) - return + if existing.ID != e.ID { + continue } + // Capture the cursor's current event ID so we can chase it through + // the move-to-front. The aggregated event itself is allowed to move + // — what we protect is selection of OTHER events. + var cursorID string + if nc.cursor >= 0 && nc.cursor < len(nc.events) { + cursorID = nc.events[nc.cursor].ID + } + + nc.events = append(nc.events[:i], nc.events[i+1:]...) + nc.events = append([]ipc.PaneEventPayload{e}, nc.events...) + + // Restore cursor onto the same logical event. When the aggregated + // event WAS the cursor, follow it to position 0 (the visual + // equivalent of "stay on the card you were looking at"). When the + // cursor was on a different event, find its new index. + if cursorID != "" { + for j, ev := range nc.events { + if ev.ID == cursorID { + nc.cursor = j + break + } + } + } + return } nc.events = append([]ipc.PaneEventPayload{e}, nc.events...) if len(nc.events) > nc.maxEvents { nc.events = nc.events[:nc.maxEvents] } + // Deliberately do NOT shift the cursor on a fresh prepend. The legacy + // contract is "cursor 0 = newest event"; a fresh event landing at index + // 0 should become the new selection by default. Only the aggregation + // move-to-front above chases the logical event by ID, because that's the + // case where the user is actively reading a card that's about to bump. } // DismissSelected removes the selected event and returns its ID. diff --git a/internal/tui/notification_active_pane_test.go b/internal/tui/notification_active_pane_test.go index e1f362e..b53c372 100644 --- a/internal/tui/notification_active_pane_test.go +++ b/internal/tui/notification_active_pane_test.go @@ -24,6 +24,7 @@ func modelForActivePaneTest(activePaneID string) Model { } func TestPaneEvent_OutputIdleOnActivePane_Suppressed(t *testing.T) { + t.Parallel() m := modelForActivePaneTest("pane-active") idle := paneEventMsg(ipc.PaneEventPayload{ ID: "evt-1", @@ -39,6 +40,7 @@ func TestPaneEvent_OutputIdleOnActivePane_Suppressed(t *testing.T) { } func TestPaneEvent_OutputIdleOnBackgroundPane_Queued(t *testing.T) { + t.Parallel() m := modelForActivePaneTest("pane-active") idle := paneEventMsg(ipc.PaneEventPayload{ ID: "evt-1", @@ -54,6 +56,7 @@ func TestPaneEvent_OutputIdleOnBackgroundPane_Queued(t *testing.T) { } func TestPaneEvent_ProcessExitOnActivePane_StillQueued(t *testing.T) { + t.Parallel() // Process exits, bells, and command completions are transient state // changes — they belong in the sidebar even when the user is looking at // the pane (the sidebar acts as a session log they can scroll back to). @@ -72,6 +75,7 @@ func TestPaneEvent_ProcessExitOnActivePane_StillQueued(t *testing.T) { } func TestPaneEvent_BellOnActivePane_StillQueued(t *testing.T) { + t.Parallel() m := modelForActivePaneTest("pane-active") bell := paneEventMsg(ipc.PaneEventPayload{ ID: "evt-1", @@ -87,6 +91,7 @@ func TestPaneEvent_BellOnActivePane_StillQueued(t *testing.T) { } func TestIsActivePane_EmptyPaneID(t *testing.T) { + t.Parallel() m := modelForActivePaneTest("pane-active") if m.isActivePane("") { t.Errorf("empty paneID must not match") @@ -94,6 +99,7 @@ func TestIsActivePane_EmptyPaneID(t *testing.T) { } func TestIsActivePane_NoActiveTab(t *testing.T) { + t.Parallel() cfg := config.Default() m := Model{ client: &fakeSender{}, diff --git a/internal/tui/notification_aggregate_test.go b/internal/tui/notification_aggregate_test.go index 0ad39d8..942e33d 100644 --- a/internal/tui/notification_aggregate_test.go +++ b/internal/tui/notification_aggregate_test.go @@ -12,6 +12,7 @@ import ( // ID and the sidebar must update the existing card in place AND bubble it // to the front (so users see the bump and ×N badge change). func TestNotificationCenter_AddEvent_SameIDUpdatesInPlace(t *testing.T) { + t.Parallel() nc := NewNotificationCenter(40, 50) nc.AddEvent(ipc.PaneEventPayload{ID: "shared", PaneID: "p1", Title: "Output idle", Message: "first"}) nc.AddEvent(ipc.PaneEventPayload{ID: "other", PaneID: "p2", Title: "Output idle", Message: "other"}) @@ -38,6 +39,7 @@ func TestNotificationCenter_AddEvent_SameIDUpdatesInPlace(t *testing.T) { } func TestNotificationCenter_View_RendersCountBadge(t *testing.T) { + t.Parallel() nc := NewNotificationCenter(40, 50) nc.visible = true nc.AddEvent(ipc.PaneEventPayload{ @@ -58,6 +60,7 @@ func TestNotificationCenter_View_RendersCountBadge(t *testing.T) { } func TestNotificationCenter_View_NoBadgeWhenCountOne(t *testing.T) { + t.Parallel() nc := NewNotificationCenter(40, 50) nc.visible = true nc.AddEvent(ipc.PaneEventPayload{ @@ -70,3 +73,42 @@ func TestNotificationCenter_View_NoBadgeWhenCountOne(t *testing.T) { t.Errorf("View must NOT render ×N for a single un-aggregated event; output:\n%s", rendered) } } + +// TestNotificationCenter_AddEvent_CursorFollowsLogicalEventOnAggregation — +// H3 invariant from the code review. When an event bumps to position 0 via +// the move-to-front aggregation path, a cursor that was pointing at a +// DIFFERENT event must follow that event's new index — never stay at the +// same numeric position (which would silently jump the user's selection +// onto a different card while they were reading it). +func TestNotificationCenter_AddEvent_CursorFollowsLogicalEventOnAggregation(t *testing.T) { + t.Parallel() + nc := NewNotificationCenter(40, 50) + // Layout after seeding: [newest, middle, watched-card]. The user selects + // watched-card at index 2 to read it. + nc.AddEvent(ipc.PaneEventPayload{ID: "watched-card", PaneID: "p-watch", Title: "Output idle"}) + nc.AddEvent(ipc.PaneEventPayload{ID: "middle", PaneID: "p-middle", Title: "Output idle"}) + nc.AddEvent(ipc.PaneEventPayload{ID: "newest", PaneID: "p-new", Title: "Output idle"}) + nc.cursor = 2 // pointing at watched-card + + // Aggregate `middle` (was at index 1) — moves to position 0. Layout + // becomes [middle, newest, watched-card]. Without the cursor-by-ID fix, + // cursor=2 would still point at watched-card (lucky no-op here). + nc.AddEvent(ipc.PaneEventPayload{ID: "middle", PaneID: "p-middle", Title: "Output idle", + Data: map[string]string{"count": "2"}}) + + if got := nc.events[nc.cursor].ID; got != "watched-card" { + t.Errorf("after middle bump, cursor must point at watched-card; landed on %q (cursor=%d)", got, nc.cursor) + } + + // Aggregate `watched-card` itself — it moves from index 2 to 0. The + // cursor must follow because it WAS on watched-card. + nc.AddEvent(ipc.PaneEventPayload{ID: "watched-card", PaneID: "p-watch", Title: "Output idle", + Data: map[string]string{"count": "2"}}) + + if got := nc.events[nc.cursor].ID; got != "watched-card" { + t.Errorf("after watched-card bump, cursor must follow to position 0; landed on %q (cursor=%d)", got, nc.cursor) + } + if nc.cursor != 0 { + t.Errorf("cursor should be at the aggregated event's new index (0); got %d", nc.cursor) + } +} diff --git a/internal/tui/notification_excerpt_test.go b/internal/tui/notification_excerpt_test.go index 00f7d65..4004a34 100644 --- a/internal/tui/notification_excerpt_test.go +++ b/internal/tui/notification_excerpt_test.go @@ -8,6 +8,7 @@ import ( ) func TestFirstNonEmptyLine(t *testing.T) { + t.Parallel() tests := []struct { name string in string @@ -30,6 +31,7 @@ func TestFirstNonEmptyLine(t *testing.T) { } func TestNotificationCenter_View_RendersExcerpt(t *testing.T) { + t.Parallel() nc := NewNotificationCenter(40, 50) nc.visible = true nc.AddEvent(ipc.PaneEventPayload{ @@ -52,6 +54,7 @@ func TestNotificationCenter_View_RendersExcerpt(t *testing.T) { } func TestNotificationCenter_View_NoExcerptStillRendersTitle(t *testing.T) { + t.Parallel() // Events without Message (legacy or empty-excerpt events) must still // render the title — the excerpt slot is just left blank. nc := NewNotificationCenter(40, 50) @@ -72,6 +75,7 @@ func TestNotificationCenter_View_NoExcerptStillRendersTitle(t *testing.T) { } func TestNotificationCenter_View_ExcerptShowsFirstLineOnly(t *testing.T) { + t.Parallel() // A multi-line Message should collapse to its first non-empty line in // the per-event sidebar card. Full text is still available via the // daemon's Data["excerpt"] for MCP consumers. From cc3ea21d20721da8a8fa2d71b6102c0b898d13d6 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:05:00 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat(notifications):=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20hook=20events=20ingest=20infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon-side plumbing for the upcoming Claude / OpenCode hook-driven notifications. No user-facing behavior change yet — the Claude .sh / opencode .js producers don't emit to the spool until Phase C — but the ingest pipeline is fully testable in isolation. New package internal/hookevents/: types.go — Payload wire schema (v=1, ts_ms, seq, pane_id, src, hook_event, session_id, transcript_path, cwd, title, sev, data). Severity constants (info/warning/error), source constants (claude/opencode), hook-side caps (200 B title, 128 B data value, 2 KiB total). Payload.Validate enforces required fields + bounded enums; sentinel errors via errors.Is. spool.go — Spool reads $QUIL_HOME/events/.jsonl files. Init truncates stale files on daemon start (no replay). Tick polls every file, remembers per-file byte offset, skips trailing partial lines (the documented append-vs-read race), drops malformed / oversize lines with a warn log. Cleanup unlinks a pane's spool file on DestroyPane. ingest.go — Ingester sits between Spool/IPC and emit. Per-pane sliding-window rate limiter (100 events / 2s; on trip emits one "internal.event_storm" diagnostic and drops further events from that pane for 10 s). Per-(paneID, hook_event) 50 ms debounce coalescer; last payload wins with data["coalesced"] = burst count. now() is overridable so tests don't depend on wall clock. Daemon wiring (internal/daemon/daemon.go): - New Daemon.hookSpool + Daemon.hookIngester fields. Init in Start() after EnsureScripts. Watcher goroutine launched alongside idleChecker, polls 200 ms, same shutdown discipline (FlushAll drains pending coalesce buffers before exit). - emitHookEvent translates Payload → PaneEvent, sets Pane.HookHealthy + Pane.LastHookEventAt under PluginMu, routes through the existing emitEvent (mute filter, aggregation, broadcast). - checkIdlePanes' shouldFire condition gains !(pane.HookHealthy && now-LastHookEventAt < 30 s) so the legacy idle excerpt steps aside for healthy hook panes. The 30 s grace period lets legacy idle reactivate as a fallback during long quiet turns — and panes whose hook plugin fails to load never trip HookHealthy at all, so they keep the legacy notification surface. - Both DestroyPane code paths (handleDestroyPane and handleDestroyPaneReq) call Spool.Cleanup before session.DestroyPane so the watcher's next tick can't race with the destroy. internal/daemon/session.go — Pane gains LastHookEventAt time.Time + HookHealthy bool, guarded by the existing PluginMu. internal/config/config.go — new EventsDir() helper returning $QUIL_HOME/events; mirrors ClaudeHookDir / SessionsDir. Tests (15 new in hookevents/): - Payload validation: happy path + 6 error rows + empty severity tolerance. - Spool: appended lines read, offset survives, partial trailing line skipped and consumed after completion, malformed lines dropped, Init truncates, Cleanup unlinks, non-jsonl files ignored, oversize line dropped. - Ingester: burst coalesce to 1 with last-wins + count metadata, different events / panes stay distinct, FlushAll drains, rate limit trips at 100/2s with storm diagnostic emitted, limiter recovers after penalty (uses overridable clock). All green under go test ./... and -race. --- internal/config/config.go | 10 + internal/daemon/daemon.go | 140 ++++++++++++++ internal/daemon/session.go | 13 ++ internal/hookevents/ingest.go | 281 +++++++++++++++++++++++++++++ internal/hookevents/ingest_test.go | 220 ++++++++++++++++++++++ internal/hookevents/spool.go | 202 +++++++++++++++++++++ internal/hookevents/spool_test.go | 223 +++++++++++++++++++++++ internal/hookevents/types.go | 167 +++++++++++++++++ internal/hookevents/types_test.go | 75 ++++++++ 9 files changed, 1331 insertions(+) create mode 100644 internal/hookevents/ingest.go create mode 100644 internal/hookevents/ingest_test.go create mode 100644 internal/hookevents/spool.go create mode 100644 internal/hookevents/spool_test.go create mode 100644 internal/hookevents/types.go create mode 100644 internal/hookevents/types_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 30a6292..9fc3885 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -282,6 +282,16 @@ func ClaudeHookDir() string { return filepath.Join(QuilDir(), "claudehook") } +// EventsDir returns the directory where Claude / opencode hooks append +// per-pane JSONL event spool files (.jsonl). The daemon's +// hookEventsWatcher polls these files on a 200 ms ticker, parses new +// lines, and feeds them through hookevents.Ingester → eventQueue → IPC +// fan-out. Truncated at daemon start (no replay of stale events); files +// for destroyed panes are unlinked. +func EventsDir() string { + return filepath.Join(QuilDir(), "events") +} + // SessionsDir returns the directory where the Claude Code SessionStart hook // writes per-pane session id files (.id). Read on daemon restore // by resumeTemplateFor so panes reattach to the latest session id after diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 20f603f..97f08e9 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -24,6 +24,7 @@ import ( "github.com/google/uuid" "github.com/artyomsv/quil/internal/claudehook" "github.com/artyomsv/quil/internal/config" + "github.com/artyomsv/quil/internal/hookevents" "github.com/artyomsv/quil/internal/ipc" "github.com/artyomsv/quil/internal/logger" memreport "github.com/artyomsv/quil/internal/memreport" @@ -59,6 +60,15 @@ type Daemon struct { memReport *memreport.Collector collectorWG sync.WaitGroup + + // hookIngester translates hookevents.Payload (from spool reads / future + // IPC submissions) into PaneEvents via emitHookEvent. Lazily initialised + // in Start once the events dir is ready; nil before Start. + hookIngester *hookevents.Ingester + // hookSpool reads $QUIL_HOME/events/.jsonl appended by the + // Claude .sh / opencode .js hook scripts. Polled by hookEventsWatcher + // every 200 ms while the daemon runs. + hookSpool *hookevents.Spool } func New(cfg config.Config) *Daemon { @@ -107,6 +117,15 @@ func (d *Daemon) Start() error { log.Printf("warning: failed to create sessions dir: %v", err) } + // Hook event ingest plumbing: spool reader + ingester (rate limit + + // coalesce) feeding emitHookEvent. Init truncates stale spool files so + // the daemon never replays notifications from a prior session. + d.hookSpool = hookevents.NewSpool(config.EventsDir()) + if err := d.hookSpool.Init(); err != nil { + log.Printf("warning: failed to init hook events spool: %v", err) + } + d.hookIngester = hookevents.NewIngester(d.emitHookEvent) + // Write default plugin TOML files if missing, then load all plugins if _, err := plugin.EnsureDefaultPlugins(config.PluginsDir()); err != nil { log.Printf("warning: failed to write default plugins: %v", err) @@ -137,6 +156,7 @@ func (d *Daemon) Start() error { } go d.idleChecker() + go d.hookEventsWatcher() ctx, cancel := context.WithCancel(context.Background()) go func() { @@ -939,6 +959,13 @@ func (d *Daemon) handleDestroyPane(msg *ipc.Message) { } log.Printf("pane destroy: %s (tab=%s)", payload.PaneID, tabID) + // Tear down the pane's hook event spool file before destroying the + // pane itself — the watcher's next tick must not pick up stale lines + // from a destroyed pane. + if d.hookSpool != nil { + d.hookSpool.Cleanup(payload.PaneID) + } + d.session.DestroyPane(payload.PaneID) // Auto-create replacement if last pane in tab was destroyed @@ -1778,6 +1805,105 @@ func (d *Daemon) emitEvent(e PaneEvent) { } // idleChecker runs a periodic check for panes that have gone idle. +// hookEventsWatcher polls the hook event spool every 200 ms while the daemon +// runs, submitting each new payload to the Ingester which then forwards +// (after rate-limit + coalesce) to emitHookEvent. Mirrors idleChecker's +// shutdown discipline: select on d.shutdown so Stop() drains cleanly. +// +// 200 ms is a tradeoff between latency and CPU. With the spool being just +// stat+seek+read per file, ten panes cost ~50 µs/tick — negligible — while +// a 200 ms p99 latency from hook fire to sidebar render keeps the user's +// perception of "instant" intact. +func (d *Daemon) hookEventsWatcher() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-d.shutdown: + // Final drain so any in-flight bursts surface before close. + if d.hookIngester != nil { + d.hookIngester.FlushAll() + } + return + case <-ticker.C: + if d.hookSpool == nil || d.hookIngester == nil { + continue + } + for _, p := range d.hookSpool.Tick() { + d.hookIngester.Submit(p) + } + } + } +} + +// emitHookEvent is the bridge from hookevents.Payload (post rate-limit and +// coalesce) to the daemon's PaneEvent emission funnel. Looks up the pane +// to enrich with TabID/Name (which the hook side does not know), marks the +// pane HookHealthy so the legacy idle checker steps aside, then routes +// through the existing emitEvent so mute, aggregation, and the broadcast +// path all apply. +// +// A pane that has been destroyed between the hook write and the spool +// read silently drops here — the lookup returns nil and we return without +// emit. Same trust boundary as the rest of the IPC surface. +func (d *Daemon) emitHookEvent(p hookevents.Payload) { + pane := d.session.Pane(p.PaneID) + if pane == nil { + logger.Debug("hook event for unknown pane=%s src=%s hook_event=%s", + p.PaneID, p.Source, p.HookEvent) + return + } + + pane.PluginMu.Lock() + pane.HookHealthy = true + pane.LastHookEventAt = time.Now() + pane.PluginMu.Unlock() + + // Compose the PaneEvent. The Type field encodes the source so MCP + // consumers can filter by "hook.claude.*" or "hook.opencode.*" without + // parsing the title. Severity defaults to info when the hook omitted it. + severity := p.Severity + if severity == "" { + severity = hookevents.SeverityInfo + } + eventType := "hook." + p.Source + "." + p.HookEvent + ts := time.UnixMilli(p.TsMs) + if p.TsMs == 0 { + ts = time.Now() + } + + // Copy Data so the Payload's map is not aliased downstream — the + // Ingester may still hold a reference, and emitEvent's aggregation may + // mutate Data["count"]. + var data map[string]string + if len(p.Data) > 0 { + data = make(map[string]string, len(p.Data)+2) + for k, v := range p.Data { + data[k] = v + } + } + // Enrich with source-tracking metadata so MCP consumers do not need to + // re-parse the Type prefix. + if data == nil { + data = make(map[string]string, 2) + } + data["hook_source"] = p.Source + data["hook_event"] = p.HookEvent + + d.emitEvent(PaneEvent{ + ID: uuid.New().String(), + PaneID: p.PaneID, + TabID: pane.TabID, + PaneName: pane.Name, + Type: eventType, + Title: p.Title, + Message: data["preview"], // optional excerpt-like preview from the hook + Severity: severity, + Timestamp: ts, + Data: data, + }) +} + func (d *Daemon) idleChecker() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -1799,9 +1925,17 @@ func (d *Daemon) checkIdlePanes() { for _, pane := range d.session.Panes(tab.ID) { // Single lock span: read + conditionally write to avoid race with flushPaneOutput pane.PluginMu.Lock() + // Suppress the legacy idle excerpt when the pane's hook is + // actively delivering ground-truth events. A 30 s grace period + // catches the case where hooks load successfully but the AI + // tool sits quiet for an extended turn — the legacy idle then + // reactivates as a fallback so the user is never left with + // zero notification signal. + hookActive := pane.HookHealthy && now.Sub(pane.LastHookEventAt) < 30*time.Second shouldFire := !pane.IdleNotified && !pane.LastOutputAt.IsZero() && pane.ExitCode == nil && + !hookActive && (pane.LastIdleEventAt.IsZero() || now.Sub(pane.LastIdleEventAt) >= cooldown) && now.Sub(pane.LastOutputAt) >= threshold if shouldFire { @@ -2480,6 +2614,12 @@ func (d *Daemon) handleDestroyPaneReq(conn *ipc.Conn, msg *ipc.Message) { } d.highlightPane(pane.ID) + // Same hook-events cleanup as handleDestroyPane: kill the spool file + // before the pane disappears so the watcher does not race the destroy. + if d.hookSpool != nil { + d.hookSpool.Cleanup(req.PaneID) + } + tabID := pane.TabID if err := d.session.DestroyPane(req.PaneID); err != nil { log.Printf("handleDestroyPaneReq: %v", err) diff --git a/internal/daemon/session.go b/internal/daemon/session.go index 72ab769..5deb2f0 100644 --- a/internal/daemon/session.go +++ b/internal/daemon/session.go @@ -51,6 +51,19 @@ type Pane struct { // Persisted in the workspace snapshot so mute survives restart. Read // under PluginMu in emitEvent. Muted bool + // LastHookEventAt is the wall-clock time of the most recent hook event + // the daemon translated into a PaneEvent for this pane. Used by + // checkIdlePanes to skip the legacy idle excerpt heuristic when hook + // events are actively flowing (the AI tool itself is the ground truth + // for what "idle" means once hooks are wired up). + LastHookEventAt time.Time + // HookHealthy flips true the first time a hook event is received for + // this pane. Provides the legacy-idle fallback: panes whose hooks + // never load (plugin throws at module init, settings JSON malformed, + // etc.) remain HookHealthy=false and the idle checker stays active — + // the user always sees SOME notification surface, even if not the + // hook-driven one. + HookHealthy bool } type SessionManager struct { diff --git a/internal/hookevents/ingest.go b/internal/hookevents/ingest.go new file mode 100644 index 0000000..f607067 --- /dev/null +++ b/internal/hookevents/ingest.go @@ -0,0 +1,281 @@ +package hookevents + +import ( + "sort" + "sync" + "time" +) + +// Ingester is the daemon-side gate between raw Spool / IPC payloads and the +// downstream emit callback that translates them to daemon.PaneEvent. It +// owns two flow-control mechanisms: +// +// 1. Per-pane sliding-window RATE LIMITER. A pane that emits > 100 events +// in any 2-second window trips the limiter; further events from that +// pane are dropped for 10 seconds and a single "storm" diagnostic is +// synthesised so the user sees the problem rather than wondering where +// the events went. +// +// 2. Per-(paneID, hook_event) COALESCER with a 50 ms debounce. When the +// same logical event fires N times within the window (e.g. the +// OpenCode "session.status busy → idle → busy → idle" flapping during +// a tool call), only the LAST payload in the window is forwarded to +// emit, with data["coalesced"] bumped to N. This keeps the eventQueue +// and the IPC fan-out from being saturated by streaming-style events +// while still surfacing the final state to the user. +// +// Both mechanisms layer on top of the eventQueue's existing same- +// (PaneID, Title) aggregation (×N badge). Coalescer collapses bursts of +// the SAME hook_event; queue aggregation collapses bursts where multiple +// hook_event types happen to map to the same human title. +type Ingester struct { + emit func(Payload) + + // now is overridable for tests so we don't depend on wall-clock time + // for rate-limiter / coalesce window assertions. Defaults to time.Now. + now func() time.Time + + mu sync.Mutex + rates map[string]*paneRate // paneID → sliding window + pending map[string]*pendingEvent // (paneID + "\x00" + hookEvent) → buffered coalesce +} + +const ( + // rateWindowSize / rateWindowDuration form the per-pane budget. 100 + // events / 2 s is generous for healthy hook activity (a Claude turn + // with 30 tool calls + a few state events is well under) and tight + // enough that a runaway hook (test loop, broken pattern matcher) + // trips within ~20 ms of starting. + rateWindowSize = 100 + rateWindowDuration = 2 * time.Second + + // stormPenaltyDuration is the dropoff after the limiter trips. Brief + // enough that a healthy pane recovers visibly; long enough to suppress + // log-stamp noise from a sustained issue. + stormPenaltyDuration = 10 * time.Second + + // coalesceDelay is the per-(paneID, hook_event) debounce. The first + // event arms a timer; subsequent events in the window replace the + // buffered payload and DO NOT re-arm. When the timer fires the last + // payload wins, with the burst count attached. + coalesceDelay = 50 * time.Millisecond +) + +type paneRate struct { + // timestamps is a small sliding window (capacity rateWindowSize). On + // each Submit we prune entries older than rateWindowDuration before + // the size check. Implemented as a slice not a ring buffer because N + // is tiny and the simplicity wins. + timestamps []time.Time + + // dropUntil is the timestamp after which we accept events again. Zero + // when the limiter is not currently tripped. + dropUntil time.Time + + // droppedCount is the number of events suppressed during the current + // penalty window. Reset to 0 when penalty clears. + droppedCount int + + // stormReported is true once the synthesised "event storm" diagnostic + // has been emitted for the current penalty window so we don't emit it + // every single dropped event. + stormReported bool +} + +type pendingEvent struct { + payload Payload + burstCount int + scheduledAt time.Time + timer *time.Timer +} + +// NewIngester returns an Ingester whose Submit forwards through the rate +// limiter and coalescer to emit. The callback should be cheap (the daemon's +// emitEvent already has its own locking and broadcast machinery). +func NewIngester(emit func(Payload)) *Ingester { + return &Ingester{ + emit: emit, + now: time.Now, + rates: make(map[string]*paneRate), + pending: make(map[string]*pendingEvent), + } +} + +// Submit accepts a payload for ingest. May be called from any goroutine. +// Returns immediately — the actual emit happens after the coalesce window +// closes (typically 50 ms later, sometimes immediately for the first event +// of a new (pane, hook_event) pair). +// +// Validation failures are silently dropped: Submit assumes the caller has +// already validated. The Spool reader does this before calling Submit; +// the IPC handler should do the same. +func (i *Ingester) Submit(p Payload) { + now := i.now() + if !i.allowAndRecord(p, now) { + return + } + i.coalesce(p, now) +} + +// allowAndRecord returns true if the payload is within budget. If it +// exceeds budget it returns false; if it crosses the threshold it emits a +// synthesised "storm" diagnostic so the user is told about the drop. +func (i *Ingester) allowAndRecord(p Payload, now time.Time) bool { + i.mu.Lock() + defer i.mu.Unlock() + + r, ok := i.rates[p.PaneID] + if !ok { + r = &paneRate{} + i.rates[p.PaneID] = r + } + + // If still inside the penalty window, drop. + if !r.dropUntil.IsZero() && now.Before(r.dropUntil) { + r.droppedCount++ + return false + } + // Clear penalty bookkeeping when window ends. + if !r.dropUntil.IsZero() && !now.Before(r.dropUntil) { + r.dropUntil = time.Time{} + r.stormReported = false + r.droppedCount = 0 + } + + // Prune timestamps older than the window. + cutoff := now.Add(-rateWindowDuration) + pruned := r.timestamps[:0] + for _, ts := range r.timestamps { + if ts.After(cutoff) { + pruned = append(pruned, ts) + } + } + r.timestamps = pruned + + if len(r.timestamps) >= rateWindowSize { + // Trip the storm. Emit one synthetic diagnostic before silencing. + r.dropUntil = now.Add(stormPenaltyDuration) + r.droppedCount = 1 + if !r.stormReported { + r.stormReported = true + // Build storm diagnostic — emit OUTSIDE the lock to avoid + // reentrancy if emit calls back into the Ingester somehow. + storm := stormPayload(p.PaneID, p.Source, now) + i.mu.Unlock() + i.emit(storm) + i.mu.Lock() + } + return false + } + + r.timestamps = append(r.timestamps, now) + return true +} + +// stormPayload synthesises the diagnostic event emitted when the per-pane +// rate limiter trips. Has no Validate-required fields wrong; reuses the +// PaneID and Source of the offending pane so it routes through the same +// downstream filters (mute, active-pane suppression, etc.). +func stormPayload(paneID, source string, now time.Time) Payload { + return Payload{ + V: SchemaVersion, + TsMs: now.UnixMilli(), + Seq: 0, + PaneID: paneID, + Source: source, + HookEvent: "internal.event_storm", + Title: "Hook event storm — silenced 10 s", + Severity: SeverityWarning, + Data: map[string]string{ + "reason": "rate_limit_exceeded", + }, + } +} + +// coalesce buffers a payload under its (paneID, hook_event) key. The first +// event in a new window arms the timer; subsequent events in the window +// replace the buffered payload and bump the burst counter. When the timer +// fires we emit the LAST buffered payload (so the freshest state wins) with +// the burst count attached so consumers can render ×N. +func (i *Ingester) coalesce(p Payload, now time.Time) { + key := p.PaneID + "\x00" + p.HookEvent + + i.mu.Lock() + pending, exists := i.pending[key] + if exists { + // Replace payload with the newer one, bump count, leave timer alone. + pending.burstCount++ + pending.payload = p + i.mu.Unlock() + return + } + pending = &pendingEvent{ + payload: p, + burstCount: 1, + scheduledAt: now, + } + pending.timer = time.AfterFunc(coalesceDelay, func() { + i.flush(key) + }) + i.pending[key] = pending + i.mu.Unlock() +} + +// flush emits the buffered payload for key and removes the pending entry. +// Called from the AfterFunc timer goroutine. +func (i *Ingester) flush(key string) { + i.mu.Lock() + pending, ok := i.pending[key] + if !ok { + i.mu.Unlock() + return + } + delete(i.pending, key) + i.mu.Unlock() + + payload := pending.payload + if pending.burstCount > 1 { + if payload.Data == nil { + payload.Data = make(map[string]string) + } + payload.Data["coalesced"] = formatUint(uint64(pending.burstCount)) + } + i.emit(payload) +} + +// formatUint is a tiny strconv.FormatUint shim — kept inline so we don't +// pull strconv across the public boundary. uint64 because Payload.Seq is +// already uint64 and the same formatter handles both. +func formatUint(v uint64) string { + if v == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + for v > 0 { + pos-- + buf[pos] = byte('0' + v%10) + v /= 10 + } + return string(buf[pos:]) +} + +// FlushAll is a test helper / shutdown helper that drains the coalescer's +// pending buffers immediately, emitting whatever is currently queued. +// Production code does not need this — the AfterFunc timers fire on their +// own — but Daemon.Stop calls it during shutdown so any in-flight bursts +// are surfaced before the IPC server tears down. +func (i *Ingester) FlushAll() { + i.mu.Lock() + keys := make([]string, 0, len(i.pending)) + for k := range i.pending { + keys = append(keys, k) + } + // Sort so the emit order is deterministic for tests. + sort.Strings(keys) + i.mu.Unlock() + + for _, k := range keys { + i.flush(k) + } +} diff --git a/internal/hookevents/ingest_test.go b/internal/hookevents/ingest_test.go new file mode 100644 index 0000000..dc95fee --- /dev/null +++ b/internal/hookevents/ingest_test.go @@ -0,0 +1,220 @@ +package hookevents + +import ( + "sync" + "testing" + "time" +) + +// emitRecorder is a thread-safe sink the Ingester emits into during tests. +type emitRecorder struct { + mu sync.Mutex + events []Payload +} + +func (r *emitRecorder) emit(p Payload) { + r.mu.Lock() + r.events = append(r.events, p) + r.mu.Unlock() +} + +func (r *emitRecorder) drain() []Payload { + r.mu.Lock() + out := append([]Payload(nil), r.events...) + r.events = nil + r.mu.Unlock() + return out +} + +func basePayload(seq uint64) Payload { + return Payload{ + V: SchemaVersion, + PaneID: "pane-1", + Source: SourceClaude, + HookEvent: "PermissionRequest", + Title: "Needs approval: Bash", + Severity: SeverityWarning, + TsMs: int64(seq), + Seq: seq, + } +} + +func TestIngester_Submit_CoalescesBurst(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + // 5 rapid submissions of the same (paneID, hook_event). Should collapse + // to 1 emit with data["coalesced"] = "5" after the 50 ms window. + for i := 1; i <= 5; i++ { + ing.Submit(basePayload(uint64(i))) + } + + // Wait past the coalesce window with a safety margin for CI slop. + time.Sleep(150 * time.Millisecond) + + got := rec.drain() + if len(got) != 1 { + t.Fatalf("burst of 5 must coalesce to 1 emit; got %d", len(got)) + } + if got[0].Seq != 5 { + t.Errorf("last-wins: got Seq=%d, want 5 (newest in window)", got[0].Seq) + } + if got[0].Data["coalesced"] != "5" { + t.Errorf("burst count: got Data[coalesced]=%q, want %q", got[0].Data["coalesced"], "5") + } +} + +func TestIngester_Submit_DifferentEventsDoNotCoalesce(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + a := basePayload(1) + a.HookEvent = "Stop" + b := basePayload(2) + b.HookEvent = "PermissionRequest" + + ing.Submit(a) + ing.Submit(b) + + time.Sleep(150 * time.Millisecond) + + got := rec.drain() + if len(got) != 2 { + t.Fatalf("two distinct hook_events: want 2 emits, got %d", len(got)) + } +} + +func TestIngester_Submit_DifferentPanesDoNotCoalesce(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + a := basePayload(1) + a.PaneID = "pane-a" + b := basePayload(2) + b.PaneID = "pane-b" + + ing.Submit(a) + ing.Submit(b) + + time.Sleep(150 * time.Millisecond) + + got := rec.drain() + if len(got) != 2 { + t.Fatalf("two distinct panes: want 2 emits, got %d", len(got)) + } +} + +func TestIngester_FlushAll_DrainsPendingImmediately(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + ing.Submit(basePayload(1)) + // Don't wait for the timer — FlushAll should emit immediately. + ing.FlushAll() + + got := rec.drain() + if len(got) != 1 { + t.Errorf("FlushAll: got %d emits, want 1", len(got)) + } +} + +func TestIngester_RateLimit_TripsAndEmitsStormDiagnostic(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + // Fire exactly rateWindowSize events distinct enough not to coalesce + // (vary HookEvent so each lands as its own coalesce key — first 1ms + // after each Submit they're independent rate counts). + for i := 0; i < rateWindowSize; i++ { + p := basePayload(uint64(i)) + p.HookEvent = "Event" + formatUint(uint64(i)) + ing.Submit(p) + } + + // One more — must trip the storm. + overflow := basePayload(uint64(rateWindowSize + 1)) + overflow.HookEvent = "Overflow" + ing.Submit(overflow) + + time.Sleep(150 * time.Millisecond) + + got := rec.drain() + // Among the emits we should find exactly one storm diagnostic. + stormCount := 0 + for _, p := range got { + if p.HookEvent == "internal.event_storm" { + stormCount++ + } + } + if stormCount != 1 { + t.Errorf("storm diagnostics: got %d, want 1", stormCount) + } + + // Further events from the same pane within the penalty window must be + // dropped — they should NOT appear in subsequent emits. + for i := 0; i < 10; i++ { + p := basePayload(uint64(1000 + i)) + p.HookEvent = "Suppressed" + formatUint(uint64(i)) + ing.Submit(p) + } + time.Sleep(150 * time.Millisecond) + tail := rec.drain() + for _, p := range tail { + if p.HookEvent != "internal.event_storm" { + // Storm-period drops mean nothing-but-storms; if any other + // emit slipped through during the penalty window, fail. + t.Errorf("event during penalty window was not dropped: %+v", p) + } + } +} + +func TestIngester_RateLimit_RecoversAfterPenalty(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + // Override the clock so we can advance through the penalty window + // without waiting 10 real seconds. + var nowMu sync.Mutex + current := time.Unix(1700000000, 0) + ing.now = func() time.Time { + nowMu.Lock() + defer nowMu.Unlock() + return current + } + + // Trip the limiter. + for i := 0; i <= rateWindowSize; i++ { + p := basePayload(uint64(i)) + p.HookEvent = "E" + formatUint(uint64(i)) + ing.Submit(p) + } + rec.drain() // discard storm + initial emits + + // Advance past the penalty + the window for clean state. + nowMu.Lock() + current = current.Add(stormPenaltyDuration + rateWindowDuration + time.Second) + nowMu.Unlock() + + // One submission AFTER recovery must succeed. + p := basePayload(9999) + p.HookEvent = "AfterRecovery" + ing.Submit(p) + ing.FlushAll() + + got := rec.drain() + found := false + for _, e := range got { + if e.HookEvent == "AfterRecovery" { + found = true + } + } + if !found { + t.Errorf("limiter must recover after penalty; AfterRecovery not emitted") + } +} diff --git a/internal/hookevents/spool.go b/internal/hookevents/spool.go new file mode 100644 index 0000000..7bee2d9 --- /dev/null +++ b/internal/hookevents/spool.go @@ -0,0 +1,202 @@ +package hookevents + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/artyomsv/quil/internal/logger" +) + +// Spool is a per-pane JSONL file reader. The daemon polls Tick on a 200 ms +// ticker; each call reads any new bytes appended since the previous read +// from every .jsonl file under the spool directory, parses one +// Payload per complete (newline-terminated) line, and returns them in +// arrival order across all files. +// +// Partial trailing lines (write in flight at the time of read) are NOT +// consumed — Tick remembers the offset of the last complete \n and resumes +// from there next call. This is the defense against the documented race +// between O_APPEND hook writes and the daemon's stat-then-read. +// +// On daemon shutdown, the spool files persist on disk; on next daemon +// start, Init truncates them so we do not replay stale events from a +// previous session (notifications are inherently ephemeral). +type Spool struct { + dir string + + mu sync.Mutex + offsets map[string]int64 // paneID → byte offset already consumed +} + +// NewSpool returns a Spool reading from dir. Use Init to truncate stale +// files on daemon startup; Tick on each poll; Cleanup on pane destroy. +func NewSpool(dir string) *Spool { + return &Spool{ + dir: dir, + offsets: make(map[string]int64), + } +} + +// Init prepares the spool directory: creates it if absent, truncates every +// existing *.jsonl file to size 0 so a fresh daemon never replays events +// from a previous run. Safe to call multiple times. +// +// Truncate-on-start trades off durability for predictability: a hook that +// fired between daemon-stop and daemon-start would be lost, but the +// alternative — replaying potentially-stale events that no longer +// represent live state — is worse for a notification surface. +func (s *Spool) Init() error { + if err := os.MkdirAll(s.dir, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(s.dir) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + path := filepath.Join(s.dir, name) + if err := os.Truncate(path, 0); err != nil && !errors.Is(err, os.ErrNotExist) { + logger.Warn("hookevents: truncate spool %q: %v", path, err) + } + } + s.mu.Lock() + s.offsets = make(map[string]int64) + s.mu.Unlock() + return nil +} + +// Tick scans the spool directory for new bytes appended since the last +// call, parses every complete line as a Payload, and returns the +// successfully-decoded payloads in arrival order per file (across files +// the order follows directory enumeration, which is not guaranteed — +// downstream coalescing keys by (paneID, hook_event) so ordering across +// panes does not affect correctness). +// +// Decoded but invalid payloads (failed Validate) are dropped with a warn +// log; the spool offset advances past them so they don't get re-parsed. +func (s *Spool) Tick() []Payload { + entries, err := os.ReadDir(s.dir) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Warn("hookevents: read spool dir %q: %v", s.dir, err) + } + return nil + } + + var out []Payload + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + paneID := strings.TrimSuffix(name, ".jsonl") + payloads := s.readPaneFile(paneID, filepath.Join(s.dir, name)) + out = append(out, payloads...) + } + return out +} + +func (s *Spool) readPaneFile(paneID, path string) []Payload { + s.mu.Lock() + off := s.offsets[paneID] + s.mu.Unlock() + + f, err := os.Open(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Warn("hookevents: open spool %q: %v", path, err) + } + return nil + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + logger.Warn("hookevents: stat spool %q: %v", path, err) + return nil + } + size := info.Size() + if size == off { + return nil // nothing new + } + if size < off { + // File was truncated externally (e.g. test harness or a future + // rotation). Restart from the beginning. + off = 0 + } + + if _, err := f.Seek(off, io.SeekStart); err != nil { + logger.Warn("hookevents: seek spool %q: %v", path, err) + return nil + } + buf, err := io.ReadAll(f) + if err != nil { + logger.Warn("hookevents: read spool %q: %v", path, err) + return nil + } + + // Find the last complete line (ending in \n). Everything past that is a + // partial trailing write that we must not consume; it will be picked up + // on the next Tick. + lastNL := bytes.LastIndexByte(buf, '\n') + if lastNL < 0 { + return nil // no complete line yet + } + consumed := off + int64(lastNL) + 1 + s.mu.Lock() + s.offsets[paneID] = consumed + s.mu.Unlock() + + complete := buf[:lastNL+1] + return parsePayloads(complete) +} + +// parsePayloads decodes a buffer of newline-delimited JSON lines, dropping +// malformed lines with a warn log and returning the valid ones. +func parsePayloads(buf []byte) []Payload { + var out []Payload + for _, line := range bytes.Split(buf, []byte("\n")) { + if len(bytes.TrimSpace(line)) == 0 { + continue + } + if len(line) > MaxTotalBytes { + logger.Warn("hookevents: payload exceeds %d-byte cap (%d bytes), dropping", MaxTotalBytes, len(line)) + continue + } + var p Payload + if err := json.Unmarshal(line, &p); err != nil { + logger.Warn("hookevents: parse payload: %v", err) + continue + } + if err := p.Validate(); err != nil { + logger.Warn("hookevents: invalid payload from pane=%s src=%s hook_event=%s: %v", + p.PaneID, p.Source, p.HookEvent, err) + continue + } + out = append(out, p) + } + return out +} + +// Cleanup removes the spool file for a destroyed pane and forgets its +// offset. Idempotent; safe to call for panes that never had a spool file. +func (s *Spool) Cleanup(paneID string) { + s.mu.Lock() + delete(s.offsets, paneID) + s.mu.Unlock() + + path := filepath.Join(s.dir, paneID+".jsonl") + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + logger.Warn("hookevents: cleanup spool %q: %v", path, err) + } +} diff --git a/internal/hookevents/spool_test.go b/internal/hookevents/spool_test.go new file mode 100644 index 0000000..f459174 --- /dev/null +++ b/internal/hookevents/spool_test.go @@ -0,0 +1,223 @@ +package hookevents + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeSpoolLine(t *testing.T, path string, p Payload) { + t.Helper() + b, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("open spool: %v", err) + } + defer f.Close() + if _, err := f.Write(append(b, '\n')); err != nil { + t.Fatalf("write spool: %v", err) + } +} + +func TestSpool_Tick_ReadsAppendedLines(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + p1 := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "Stop", Title: "Reply ready", Severity: SeverityInfo, TsMs: 1, Seq: 1} + p2 := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "PermissionRequest", Title: "Needs approval: Bash", Severity: SeverityWarning, TsMs: 2, Seq: 2} + writeSpoolLine(t, path, p1) + writeSpoolLine(t, path, p2) + + got := s.Tick() + if len(got) != 2 { + t.Fatalf("Tick: got %d payloads, want 2", len(got)) + } + if got[0].HookEvent != "Stop" || got[1].HookEvent != "PermissionRequest" { + t.Errorf("Tick order: got %s, %s", got[0].HookEvent, got[1].HookEvent) + } +} + +func TestSpool_Tick_OffsetSurvivesAcrossCalls(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + p1 := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "Stop", Title: "t", Severity: SeverityInfo, TsMs: 1, Seq: 1} + writeSpoolLine(t, path, p1) + + first := s.Tick() + if len(first) != 1 { + t.Fatalf("first Tick: got %d, want 1", len(first)) + } + + // Second Tick with no new writes — must return zero. + second := s.Tick() + if len(second) != 0 { + t.Errorf("second Tick (no new lines): got %d, want 0", len(second)) + } + + // Append more lines, Tick again — only the new ones. + p2 := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "PermissionRequest", Title: "t", Severity: SeverityInfo, TsMs: 2, Seq: 2} + writeSpoolLine(t, path, p2) + third := s.Tick() + if len(third) != 1 || third[0].HookEvent != "PermissionRequest" { + t.Errorf("third Tick: got %+v, want 1 PermissionRequest", third) + } +} + +func TestSpool_Tick_SkipsPartialTrailingLine(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + // Write one complete line then a partial line with no trailing newline + // — simulates a hook write that the daemon polls in the middle of. + complete := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "Stop", Title: "Reply ready", Severity: SeverityInfo, TsMs: 1, Seq: 1} + b, _ := json.Marshal(complete) + partial := `{"v":1,"pane_id":"pane-1","src":"claude","hook_event":"PermissionRequest","title":"partial"` + if err := os.WriteFile(path, append(append(b, '\n'), []byte(partial)...), 0o600); err != nil { + t.Fatalf("write spool: %v", err) + } + + got := s.Tick() + if len(got) != 1 || got[0].HookEvent != "Stop" { + t.Errorf("Tick should consume only the complete line; got %+v", got) + } + + // Now flush the partial by appending the missing close. The partial + // content is whatever remains after Stop's newline; finishing the JSON + // + adding a newline turns it into a valid second line. + finish := `,"sev":"warning"}` + "\n" + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + f.WriteString(finish) + f.Close() + + got2 := s.Tick() + if len(got2) != 1 || got2[0].HookEvent != "PermissionRequest" { + t.Errorf("after partial completion: got %+v, want 1 PermissionRequest", got2) + } +} + +func TestSpool_Tick_DropsMalformed(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + garbage := "{not valid json\n" + good := Payload{V: SchemaVersion, PaneID: "pane-1", Source: SourceClaude, HookEvent: "Stop", Title: "Reply ready", Severity: SeverityInfo, TsMs: 1, Seq: 1} + gb, _ := json.Marshal(good) + if err := os.WriteFile(path, append([]byte(garbage), append(gb, '\n')...), 0o600); err != nil { + t.Fatalf("write spool: %v", err) + } + + got := s.Tick() + if len(got) != 1 || got[0].HookEvent != "Stop" { + t.Errorf("Tick should drop garbage and keep the valid line; got %+v", got) + } +} + +func TestSpool_Init_TruncatesExistingFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Pre-seed a stale file as though a previous daemon left it. + path := filepath.Join(dir, "pane-old.jsonl") + if err := os.WriteFile(path, []byte("stale content from previous run\n"), 0o600); err != nil { + t.Fatalf("seed spool: %v", err) + } + + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after init: %v", err) + } + if info.Size() != 0 { + t.Errorf("Init should truncate stale spool; got size %d", info.Size()) + } +} + +func TestSpool_Cleanup_RemovesFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + if err := os.WriteFile(path, []byte("noise\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + s.Cleanup("pane-1") + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("Cleanup should unlink spool; stat err = %v", err) + } +} + +func TestSpool_Tick_IgnoresNonJSONLFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + // A non-jsonl file should be silently ignored. + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + got := s.Tick() + if len(got) != 0 { + t.Errorf("non-.jsonl files must be ignored; got %d payloads", len(got)) + } +} + +func TestSpool_Tick_DropsOversizeLine(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewSpool(dir) + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + + path := filepath.Join(dir, "pane-1.jsonl") + // A line larger than MaxTotalBytes must be dropped silently. + big := strings.Repeat("x", MaxTotalBytes+10) + "\n" + if err := os.WriteFile(path, []byte(big), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + got := s.Tick() + if len(got) != 0 { + t.Errorf("oversize line must be dropped; got %d payloads", len(got)) + } +} diff --git a/internal/hookevents/types.go b/internal/hookevents/types.go new file mode 100644 index 0000000..f69eb1f --- /dev/null +++ b/internal/hookevents/types.go @@ -0,0 +1,167 @@ +// Package hookevents defines the wire format and ingest pipeline for +// notifications sourced from Claude Code and OpenCode hooks. +// +// Wire path (v1): +// +// hook fires (claude .sh / opencode .js) +// │ +// ├─ writes one JSONL line to $QUIL_HOME/events/.jsonl (primary) +// │ ─ append-only, single-write per line (atomic under PIPE_BUF) +// │ ─ daemon polls every 200 ms via Spool.Tick +// │ +// └─ (future) framed MsgHookEvent over the daemon's unix socket (opt) +// ─ OpenCode JS plugin can use this directly via node:net +// ─ cmd/quil-hook helper binary deferred (Phase D measurement) +// +// daemon ingest goroutine +// │ +// ├─ Ingester.Submit(p Payload) +// │ ├─ rate limit (per-pane sliding window, 100 / 2s; storm → drop 10s) +// │ ├─ coalesce (per-(paneID, hook_event) 50ms debounce; last-wins) +// │ └─ emit(p) (callback to daemon → daemon.PaneEvent → existing +// │ eventQueue.Push + Broadcast machinery) +// │ +// └─ Pane.LastHookEventAt + Pane.HookHealthy updated, so checkIdlePanes +// can skip the legacy idle excerpt for panes whose hooks are healthy +// (and re-enable it as fallback when hooks fail to load). +package hookevents + +import "errors" + +// SchemaVersion is the wire-protocol version of Payload. The hook side stamps +// every JSONL line with v: SchemaVersion; the daemon rejects payloads at +// other versions so a breaking schema change can be detected and surfaced as +// a user-visible diagnostic instead of silently misbehaving. +const SchemaVersion = 1 + +// Severity values used by the hook side. The daemon translates these to +// PaneEvent.Severity 1:1 and the TUI sidebar renders the colour from the +// existing severityNameStyle map. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityError = "error" +) + +// Source values for Payload.Source. Hooks stamp their own source so the +// daemon can disambiguate when a single pane runs both (e.g. an opencode +// pane that internally invokes claude). +const ( + SourceClaude = "claude" + SourceOpenCode = "opencode" +) + +// Hook-side wire-size caps. The hook is responsible for truncating before +// the line hits the spool / socket; the daemon enforces an outer cap as +// belt-and-suspenders during ingest. These are smaller than the PaneEvent +// caps in internal/daemon/event.go (4 KiB Message, 1 KiB per Data value) +// because the wire schema duplicates the title/severity/event-type fields +// outside of Data; we keep total below the 2 KiB ceiling to leave plenty +// of headroom for the IPC fan-out broadcast. +const ( + MaxTitleBytes = 200 + MaxDataValueBytes = 128 + MaxTotalBytes = 2 * 1024 +) + +// Sentinel errors returned by validators / parsers. Callers compare with +// errors.Is so the surface is stable across error-wrapping changes. +var ( + ErrMissingPaneID = errors.New("hookevents: payload missing pane_id") + ErrMissingTitle = errors.New("hookevents: payload missing title") + ErrUnknownSeverity = errors.New("hookevents: unknown severity") + ErrSchemaVersion = errors.New("hookevents: unsupported schema version") + ErrOversizePayload = errors.New("hookevents: payload exceeds 2 KiB cap") + ErrEmptyHookEvent = errors.New("hookevents: payload missing hook_event") + ErrUnknownSource = errors.New("hookevents: unknown source") +) + +// Payload is the wire schema for a single hook event. Hook scripts JSON-encode +// one Payload per spool line; the daemon decodes and validates each line +// before feeding it to the Ingester. +// +// Fields use snake_case JSON tags because the JS plugin (OpenCode) and shell +// `printf` (Claude) producers find that easier than camelCase. Optional +// fields use omitempty so a minimal Payload is short enough to keep small +// events well under the 2 KiB cap. +type Payload struct { + // V is the schema version. Must equal SchemaVersion (1) currently. + V int `json:"v"` + + // TsMs is the wall-clock timestamp at the hook's perspective (Unix ms). + // The daemon translates to time.Time via UnixMilli at the IPC boundary. + TsMs int64 `json:"ts_ms"` + + // Seq is a per-(PaneID, Source) monotonic counter set by the hook side. + // Two events landing in the same millisecond need a tiebreaker so the + // 50ms coalesce buffer can preserve order; Seq supplies it. + Seq uint64 `json:"seq"` + + // PaneID is the Quil pane id (passed to the hook via QUIL_PANE_ID env). + PaneID string `json:"pane_id"` + + // Source identifies which tool emitted the event: SourceClaude or + // SourceOpenCode. Determines which set of HookEvent values are valid. + Source string `json:"src"` + + // HookEvent is the raw event name from the upstream tool (e.g. claude's + // "PermissionRequest", opencode's "permission.ask"). Kept as a string + // rather than enum so a future event type from upstream doesn't require + // a daemon update before the hook can forward it. + HookEvent string `json:"hook_event"` + + // SessionID is the AI tool's current session id when known. Optional. + SessionID string `json:"session_id,omitempty"` + + // TranscriptPath is Claude's transcript path when the upstream event + // provides it. Optional, claude-only. + TranscriptPath string `json:"transcript_path,omitempty"` + + // CWD is the pane's working directory at the time the hook fired. + // Optional but typically set so a notification card can show context. + CWD string `json:"cwd,omitempty"` + + // Title is the human-readable summary that lands as PaneEvent.Title. + // Capped to MaxTitleBytes; hook side truncates with "…" suffix. + Title string `json:"title"` + + // Severity is one of SeverityInfo, SeverityWarning, SeverityError. + Severity string `json:"sev"` + + // Data carries event-specific structured metadata (tool name, exit + // code, command preview, file paths, etc.). Each value is capped to + // MaxDataValueBytes; the daemon's PaneEvent ingest applies a second + // per-event 1 KiB Data cap (see internal/daemon/event.go) as a backstop. + Data map[string]string `json:"data,omitempty"` +} + +// Validate checks the schema invariants enforced before ingest. The hook +// scripts are responsible for producing well-formed payloads; this is the +// daemon's safety net. +func (p Payload) Validate() error { + if p.V != SchemaVersion { + return ErrSchemaVersion + } + if p.PaneID == "" { + return ErrMissingPaneID + } + if p.HookEvent == "" { + return ErrEmptyHookEvent + } + if p.Title == "" { + return ErrMissingTitle + } + switch p.Severity { + case SeverityInfo, SeverityWarning, SeverityError, "": + // "" tolerated and treated as Info downstream; some hooks emit no + // explicit severity for low-stakes events. + default: + return ErrUnknownSeverity + } + switch p.Source { + case SourceClaude, SourceOpenCode: + default: + return ErrUnknownSource + } + return nil +} diff --git a/internal/hookevents/types_test.go b/internal/hookevents/types_test.go new file mode 100644 index 0000000..0353e97 --- /dev/null +++ b/internal/hookevents/types_test.go @@ -0,0 +1,75 @@ +package hookevents + +import ( + "errors" + "testing" +) + +func TestPayload_Validate_Happy(t *testing.T) { + t.Parallel() + p := Payload{ + V: SchemaVersion, + PaneID: "pane-abc", + Source: SourceClaude, + HookEvent: "PermissionRequest", + Title: "Needs approval: Bash", + Severity: SeverityWarning, + } + if err := p.Validate(); err != nil { + t.Errorf("happy-path payload should validate; got %v", err) + } +} + +func TestPayload_Validate_Errors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mut func(p *Payload) + want error + }{ + {"wrong schema version", func(p *Payload) { p.V = 99 }, ErrSchemaVersion}, + {"missing pane id", func(p *Payload) { p.PaneID = "" }, ErrMissingPaneID}, + {"missing hook event", func(p *Payload) { p.HookEvent = "" }, ErrEmptyHookEvent}, + {"missing title", func(p *Payload) { p.Title = "" }, ErrMissingTitle}, + {"unknown severity", func(p *Payload) { p.Severity = "fatal" }, ErrUnknownSeverity}, + {"unknown source", func(p *Payload) { p.Source = "cursor" }, ErrUnknownSource}, + } + + base := Payload{ + V: SchemaVersion, + PaneID: "pane-abc", + Source: SourceClaude, + HookEvent: "Stop", + Title: "Reply ready", + Severity: SeverityInfo, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := base + tt.mut(&p) + err := p.Validate() + if !errors.Is(err, tt.want) { + t.Errorf("Validate: got %v, want %v", err, tt.want) + } + }) + } +} + +func TestPayload_Validate_EmptySeverityAllowed(t *testing.T) { + // Some hooks fire without an explicit severity; the daemon treats it as + // info downstream. The validator must accept the empty string. + t.Parallel() + p := Payload{ + V: SchemaVersion, + PaneID: "pane-abc", + Source: SourceOpenCode, + HookEvent: "session.idle", + Title: "Reply ready", + Severity: "", + } + if err := p.Validate(); err != nil { + t.Errorf("empty severity should be allowed; got %v", err) + } +} From d4f41282e537e401a38197146689a732973b9fd1 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:09:37 +0200 Subject: [PATCH 10/13] =?UTF-8?q?feat(notifications):=20Phase=20C=20?= =?UTF-8?q?=E2=80=94=20Claude=20+=20OpenCode=20hook=20script=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude (internal/claudehook/): - BuildSettingsJSON now registers Quil's hook command under 12 events (SessionStart + SessionEnd + UserPromptSubmit + Notification + PermissionRequest + Stop + PreCompact + PostCompact + SubagentStart + SubagentStop + TaskCreated + TaskCompleted) instead of SessionStart alone. The script branches on hook_event_name from stdin. - scripts/quil-session-hook.sh and .ps1 rewritten as multi-event routers. SessionStart keeps the original session-id-file behaviour (used by Quil's restore path); the other 11 events compose a v1 hookevents.Payload and append one JSONL line to $QUIL_HOME/events/.jsonl. Title/severity mapping per the plan: SessionEnd → "Session ended" info UserPromptSubmit → "Working on: " info Notification → pass-through warning PermissionRequest → "Needs approval: " warning Stop → "Reply ready" warning PreCompact → "Compacting context" info PostCompact → "Compaction complete" info SubagentStart → "Spawned: " info SubagentStop → " done" info TaskCreated → "Task: " info TaskCompleted → "✓ " info Wire caps enforced at the hook side (title ≤ 200 chars, data values ≤ 128 chars, total ≤ 2 KiB) so the daemon's outer cap is purely a backstop. Title and previews are truncated with "…" suffix. - New TestBuildSettingsJSON_RegistersAllForwardedEvents pins the 12-event registration so a future refactor that drops an entry from forwardedHookEvents fails fast. OpenCode (internal/opencodehook/scripts/quil-session-tracker.js): - Plugin keeps the original session-id rotation tracking (recording to opencode-.id for the resume path). - Generic `event` handler additionally forwards a filtered tier to the spool: session.idle → "Reply ready" warning session.error → "Session error: " error session.compacted → "Compaction complete" info session.status (retry) → "Retrying API (attempt N)" warning file.edited → "Edited N files" info (batched 1s window, single line per burst) - Typed permission.ask hook → "Needs approval: " warning. - Typed experimental.session.compacting → "Compacting context" info. - Per-pane in-process token bucket (20 events/s sustained, 50 burst). Drops with a single warn-log when exhausted; recovers as the bucket refills. Independent of the daemon-side limiter (which lives in the Ingester) so two layers of defense protect the IPC fan-out. - Wire caps enforced before JSON.stringify: title ≤ 200 bytes UTF-8, each data value ≤ 128 bytes UTF-8, total line ≤ 2 KiB; oversize lines are dropped with a hook.log breadcrumb. - Per-pane seq counter so the daemon's coalescer has a tiebreaker when two events land in the same millisecond. No daemon changes — the Phase B infrastructure consumes whatever the hooks produce. All green under go test ./... --- internal/claudehook/claudehook.go | 40 ++- internal/claudehook/claudehook_test.go | 19 ++ .../claudehook/scripts/quil-session-hook.ps1 | 178 ++++++++--- .../claudehook/scripts/quil-session-hook.sh | 261 +++++++++++++--- .../scripts/quil-session-tracker.js | 282 +++++++++++++++--- 5 files changed, 651 insertions(+), 129 deletions(-) diff --git a/internal/claudehook/claudehook.go b/internal/claudehook/claudehook.go index 69ea4f7..ce62789 100644 --- a/internal/claudehook/claudehook.go +++ b/internal/claudehook/claudehook.go @@ -163,16 +163,42 @@ type hookEntry struct { Command string `json:"command"` } +// forwardedHookEvents lists the Claude Code hook events Quil registers in +// the inline --settings JSON. The script branches on hook_event_name from +// stdin so the same command handles every entry. +// +// SessionStart is the original — it still writes the session id file used +// by the resume-args path. The rest are the v1 "default" tier from the +// hook events plan: notification + permission + lifecycle. PreToolUse / +// PostToolUse and friends are NOT in this list (too noisy at scale); +// they are reachable in verbose mode by editing the user config. +var forwardedHookEvents = []string{ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Notification", + "PermissionRequest", + "Stop", + "PreCompact", + "PostCompact", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", +} + // BuildSettingsJSON returns the inline JSON string Quil passes to -// `claude --settings `. The returned payload registers a single -// SessionStart hook whose command is `cmd`. +// `claude --settings `. Registers Quil's hook command under every +// entry in forwardedHookEvents — the script then branches on the +// hook_event_name field of the stdin JSON. func BuildSettingsJSON(cmd string) (string, error) { s := settingsSchema{ - Hooks: map[string][]hookMatcher{ - "SessionStart": {{ - Hooks: []hookEntry{{Type: "command", Command: cmd}}, - }}, - }, + Hooks: make(map[string][]hookMatcher, len(forwardedHookEvents)), + } + for _, name := range forwardedHookEvents { + s.Hooks[name] = []hookMatcher{{ + Hooks: []hookEntry{{Type: "command", Command: cmd}}, + }} } b, err := json.Marshal(s) if err != nil { diff --git a/internal/claudehook/claudehook_test.go b/internal/claudehook/claudehook_test.go index 1214d8e..d012db3 100644 --- a/internal/claudehook/claudehook_test.go +++ b/internal/claudehook/claudehook_test.go @@ -115,6 +115,25 @@ func TestBuildSettingsJSON_ContainsExpectedKeys(t *testing.T) { } } +// TestBuildSettingsJSON_RegistersAllForwardedEvents pins the multi-event +// registration introduced by Phase C. Every name in forwardedHookEvents +// must appear in the JSON so the same hook script is invoked for the full +// notification tier (not just SessionStart). A future contributor who +// removes a name from the slice without expecting the JSON to shrink will +// see this test fail with the missing name. +func TestBuildSettingsJSON_RegistersAllForwardedEvents(t *testing.T) { + t.Parallel() + js, err := BuildSettingsJSON("sh /tmp/hook.sh") + if err != nil { + t.Fatalf("BuildSettingsJSON: %v", err) + } + for _, name := range forwardedHookEvents { + if !strings.Contains(js, `"`+name+`"`) { + t.Errorf("BuildSettingsJSON missing event registration %q in output: %s", name, js) + } + } +} + func TestBuildSettingsJSON_EscapesQuotesInCommand(t *testing.T) { t.Parallel() js, err := BuildSettingsJSON(`sh "/tmp/with quotes/hook.sh"`) diff --git a/internal/claudehook/scripts/quil-session-hook.ps1 b/internal/claudehook/scripts/quil-session-hook.ps1 index c5d4f46..fc21a37 100644 --- a/internal/claudehook/scripts/quil-session-hook.ps1 +++ b/internal/claudehook/scripts/quil-session-hook.ps1 @@ -1,12 +1,17 @@ -# Quil SessionStart hook for Claude Code. +# Quil hook handler for Claude Code (multi-event v1, Windows). # -# Windows / pwsh variant. Receives Claude's hook JSON on stdin, extracts -# session_id, writes it atomically to $QUIL_HOME/sessions/$QUIL_PANE_ID.id. -# QUIL_PANE_ID is set by Quil at pane spawn; when unset the hook is a no-op. -# Always exits 0 so Claude is never blocked by our bookkeeping. +# PowerShell variant matching scripts/quil-session-hook.sh. See the .sh +# header for the full design; this file mirrors the same routing table: # -# Failure breadcrumbs land in $QUIL_HOME/claudehook/hook.log so a silently -# broken hook is detectable from the logs. +# - SessionStart → atomically write the session id to +# $QUIL_HOME/sessions/$QUIL_PANE_ID.id (used by Quil's restore path). +# +# - Every other forwarded event → append one JSONL line to +# $QUIL_HOME/events/$QUIL_PANE_ID.jsonl carrying the hookevents wire +# schema. The daemon's watcher polls this file every 200 ms. +# +# QUIL_PANE_ID and QUIL_HOME are set by Quil at pane spawn. Unset → no-op. +# Always exits 0 so Claude is never blocked. $ErrorActionPreference = 'SilentlyContinue' @@ -26,40 +31,143 @@ function Write-HookErr([string]$msg) { } catch {} } -$sessionsDir = Join-Path $quilHome 'sessions' -try { - New-Item -ItemType Directory -Path $sessionsDir -Force | Out-Null -} catch { - Write-HookErr "mkdir sessions dir failed: $sessionsDir" - exit 0 +$payload = [Console]::In.ReadToEnd() + +# Try the structured ConvertFrom-Json first; fall back to regex extraction +# if the payload is malformed. We need hook_event_name plus the per-event +# specific fields. +$obj = $null +try { $obj = $payload | ConvertFrom-Json } catch { } + +function Json-Field([string]$key) { + if ($obj -and ($obj.PSObject.Properties.Name -contains $key)) { + $v = $obj.$key + if ($null -ne $v) { return [string]$v } + } + if ($payload -match ('"' + [regex]::Escape($key) + '"\s*:\s*"([^"]*)"')) { + return $Matches[1] + } + return '' } -$payload = [Console]::In.ReadToEnd() -$sessionId = $null -try { - $obj = $payload | ConvertFrom-Json - $sessionId = $obj.session_id -} catch { - if ($payload -match '"session_id"\s*:\s*"([^"]+)"') { $sessionId = $Matches[1] } +$hookEvent = Json-Field 'hook_event_name' +$sessionId = Json-Field 'session_id' + +# Truncate a string defensively to N chars, appending an ellipsis if cut. +function Truncate([string]$s, [int]$n) { + if ($null -eq $s) { return '' } + if ($s.Length -le $n) { return $s } + return $s.Substring(0, [Math]::Max(0, $n - 1)) + "…" +} + +# Escape a string for embedding in a JSON string literal. +function JsonEscape([string]$s) { + if ($null -eq $s) { return '' } + $s = $s -replace '\\', '\\\\' + $s = $s -replace '"', '\"' + $s = $s -replace "`n", '\n' + $s = $s -replace "`r", '\r' + $s = $s -replace "`t", '\t' + return $s } -if (-not $sessionId) { - Write-HookErr "no session_id extracted from stdin" - exit 0 + +function Spool-Event([string]$he, [string]$title, [string]$sev, [string]$dataJson) { + $eventsDir = Join-Path $quilHome 'events' + try { New-Item -ItemType Directory -Path $eventsDir -Force | Out-Null } catch { + Write-HookErr ("mkdir events dir failed: {0}" -f $eventsDir) + return + } + $spoolFile = Join-Path $eventsDir ($paneId + '.jsonl') + + $tsMs = [int64]([Math]::Floor((Get-Date -UFormat %s)) * 1000) + + $heE = JsonEscape $he + $tiE = JsonEscape (Truncate $title 200) + $sidE = JsonEscape $sessionId + $pidE = JsonEscape $paneId + + $line = '{"v":1,"ts_ms":' + $tsMs + ',"seq":0,"pane_id":"' + $pidE + '","src":"claude","hook_event":"' + $heE + '","session_id":"' + $sidE + '","title":"' + $tiE + '","sev":"' + $sev + '"' + if ($dataJson) { + $line = $line + ',"data":' + $dataJson + } + $line = $line + '}' + + try { + Add-Content -Path $spoolFile -Value $line -Encoding UTF8 + } catch { + Write-HookErr ("write spool failed: {0}" -f $_.Exception.Message) + } } -# Defence-in-depth: only accept uuid-shaped values. -if ($sessionId -notmatch '^[0-9a-fA-F-]{32,64}$') { - Write-HookErr ("session_id rejected as non-uuid: {0}" -f $sessionId) - exit 0 +function Write-SessionFile { + if (-not $sessionId) { + Write-HookErr "no session_id extracted from stdin" + return + } + if ($sessionId -notmatch '^[0-9a-fA-F-]{32,64}$') { + Write-HookErr ("session_id rejected as non-uuid: {0}" -f $sessionId) + return + } + + $sessionsDir = Join-Path $quilHome 'sessions' + try { New-Item -ItemType Directory -Path $sessionsDir -Force | Out-Null } catch { + Write-HookErr ("mkdir sessions dir failed: {0}" -f $sessionsDir) + return + } + $out = Join-Path $sessionsDir ($paneId + '.id') + $tmp = "$out.{0}" -f ([guid]::NewGuid().ToString('N')) + try { + Set-Content -Path $tmp -Value $sessionId -Encoding ASCII + Move-Item -Path $tmp -Destination $out -Force + } catch { + Write-HookErr "write/rename failed: $($_.Exception.Message)" + Remove-Item -Path $tmp -ErrorAction SilentlyContinue + } } -$out = Join-Path $sessionsDir ($paneId + '.id') -$tmp = "$out.{0}" -f ([guid]::NewGuid().ToString('N')) -try { - Set-Content -Path $tmp -Value $sessionId -Encoding ASCII - Move-Item -Path $tmp -Destination $out -Force -} catch { - Write-HookErr "write/rename failed: $($_.Exception.Message)" - Remove-Item -Path $tmp -ErrorAction SilentlyContinue +switch ($hookEvent) { + 'SessionStart' { Write-SessionFile } + 'SessionEnd' { Spool-Event 'SessionEnd' 'Session ended' 'info' '' } + 'UserPromptSubmit' { + $prompt = Json-Field 'prompt' + $preview = Truncate $prompt 60 + Spool-Event 'UserPromptSubmit' ("Working on: " + $preview) 'info' ('{"prompt_preview":"' + (JsonEscape $preview) + '"}') + } + 'Notification' { + $message = Json-Field 'message' + Spool-Event 'Notification' (Truncate $message 200) 'warning' '' + } + 'PermissionRequest' { + $tool = Json-Field 'tool_name' + Spool-Event 'PermissionRequest' ("Needs approval: " + $tool) 'warning' ('{"tool":"' + (JsonEscape $tool) + '"}') + } + 'Stop' { Spool-Event 'Stop' 'Reply ready' 'warning' '' } + 'PreCompact' { + $reason = Json-Field 'reason' + $title = 'Compacting context' + if ($reason) { $title = $title + ' (' + $reason + ')' } + Spool-Event 'PreCompact' $title 'info' ('{"reason":"' + (JsonEscape $reason) + '"}') + } + 'PostCompact' { Spool-Event 'PostCompact' 'Compaction complete' 'info' '' } + 'SubagentStart' { + $agent = Json-Field 'agent_type' + Spool-Event 'SubagentStart' ("Spawned: " + $agent) 'info' ('{"agent_type":"' + (JsonEscape $agent) + '"}') + } + 'SubagentStop' { + $agent = Json-Field 'agent_type' + Spool-Event 'SubagentStop' ($agent + ' done') 'info' ('{"agent_type":"' + (JsonEscape $agent) + '"}') + } + 'TaskCreated' { + $content = Json-Field 'content' + $contentT = Truncate $content 180 + Spool-Event 'TaskCreated' ("Task: " + $contentT) 'info' ('{"content":"' + (JsonEscape $contentT) + '"}') + } + 'TaskCompleted' { + $content = Json-Field 'content' + $contentT = Truncate $content 180 + Spool-Event 'TaskCompleted' ("✓ " + $contentT) 'info' ('{"content":"' + (JsonEscape $contentT) + '"}') + } + default { Write-HookErr ("unhandled hook_event: {0}" -f $hookEvent) } } + exit 0 diff --git a/internal/claudehook/scripts/quil-session-hook.sh b/internal/claudehook/scripts/quil-session-hook.sh index 4ff9c1c..04a78ed 100644 --- a/internal/claudehook/scripts/quil-session-hook.sh +++ b/internal/claudehook/scripts/quil-session-hook.sh @@ -1,14 +1,27 @@ #!/bin/sh -# Quil SessionStart hook for Claude Code. +# Quil hook handler for Claude Code (multi-event v1). # -# Receives Claude's hook JSON on stdin, extracts session_id, and writes it -# atomically to $QUIL_HOME/sessions/$QUIL_PANE_ID.id. QUIL_PANE_ID is set by -# Quil at pane spawn; when unset the hook is a no-op (Claude invoked outside -# Quil). Always exits 0 so Claude is never blocked by our bookkeeping. +# Quil registers this script under multiple Claude hook events via +# --settings. Claude invokes it once per fired event with a JSON payload on +# stdin including a `hook_event_name` discriminator and event-specific +# fields. We branch on hook_event_name and route to one of two outputs: # -# Failure breadcrumbs are appended to $QUIL_HOME/claudehook/hook.log so a -# silently-broken hook is detectable from the logs (otherwise the rotation -# tracking would silently regress to the preassigned session id). +# - SessionStart → atomically write the session id to +# $QUIL_HOME/sessions/$QUIL_PANE_ID.id so Quil's restore path can +# dispatch --resume vs --continue. (Unchanged from the original +# SessionStart-only hook.) +# +# - Every other forwarded event → append one JSONL line to +# $QUIL_HOME/events/$QUIL_PANE_ID.jsonl carrying the Quil hookevents +# wire schema (v=1, ts_ms, seq, pane_id, src=claude, hook_event, title, +# sev, data). The daemon's hookEventsWatcher picks the line up within +# 200 ms, runs it through rate-limit + coalesce, and emits a PaneEvent. +# +# QUIL_PANE_ID and QUIL_HOME are set by Quil at pane spawn. When either is +# unset the hook is a no-op (Claude invoked outside Quil). +# +# Always exit 0 so Claude is never blocked by our bookkeeping. Failure +# breadcrumbs land in $QUIL_HOME/claudehook/hook.log. set -u @@ -28,47 +41,201 @@ log_err() { >>"$log_file" 2>/dev/null || true } -sessions_dir="$quil_home/sessions" -if ! mkdir -p "$sessions_dir" 2>/dev/null; then - log_err "mkdir sessions dir failed: $sessions_dir" - exit 0 -fi - +# Stdin is consumed exactly once; everything else reads from $payload. payload="$(cat)" -session_id="" -if command -v jq >/dev/null 2>&1; then - session_id="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null || true)" -fi -if [ -z "$session_id" ]; then - session_id="$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" -fi -if [ -z "$session_id" ]; then - log_err "no session_id extracted from stdin" - exit 0 -fi +# Extract a JSON string field by key. Tries jq first, falls back to a +# best-effort sed for environments without jq. The sed regex deliberately +# tolerates whitespace + escaped quotes only by giving up — falling back to +# empty is safe because callers treat empty as absent. +jget() { + key="$1" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$payload" | jq -r ".$key // empty" 2>/dev/null + return + fi + printf '%s' "$payload" | \ + sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} -# Defence-in-depth: only accept uuid-shaped values so a malformed payload can't -# poison the persisted id with arbitrary text. -if ! printf '%s' "$session_id" | grep -Eq '^[0-9a-fA-F-]{32,64}$'; then - log_err "session_id rejected as non-uuid: $session_id" - exit 0 -fi +hook_event="$(jget hook_event_name)" +session_id="$(jget session_id)" + +# Truncate to the wire-schema limits before any composition. Claude's +# inputs (prompt, tool args, etc.) can be arbitrarily large; we cap at +# 256 chars for any preview field and let the daemon enforce the 1 KiB +# Data value backstop. Title cap is 200. +truncate() { + awk -v n="$2" 'BEGIN { v=ARGV[1]; if (length(v) <= n) print v; else print substr(v, 1, n-1) "…"; }' "$1" 2>/dev/null +} + +# json_escape escapes a string for embedding inside a JSON string literal. +# Handles backslash, double-quote, newline, carriage return, and tab; other +# control bytes pass through (the daemon's json.Unmarshal will reject them +# and the line will be dropped — acceptable since this is best-effort +# escaping in a shell script). +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e ':a' -e 'N' -e '$!ba' \ + -e 's/\n/\\n/g' -e 's/\r/\\r/g' -e 's/\t/\\t/g' +} + +# Append a JSONL event line to the spool. Single write(2) keeps it atomic +# under PIPE_BUF on Unix (the schema cap of 2 KiB stays well under the +# typical 4 KiB PIPE_BUF). Args: 1=hook_event, 2=title, 3=severity, +# 4=data_json (already a {"k":"v",...} string, may be empty for none). +spool() { + he="$1" + ti="$2" + sv="$3" + da="$4" + events_dir="$quil_home/events" + if ! mkdir -p "$events_dir" 2>/dev/null; then + log_err "mkdir events dir failed: $events_dir" + return 0 + fi + spool_file="$events_dir/$pane_id.jsonl" + + ts_ms="$(date +%s%3N 2>/dev/null || echo 0)" + # %3N is GNU date; macOS BSD date lacks it. Fall back to seconds × 1000. + case "$ts_ms" in + ''|*[!0-9]*) ts_ms="$(( $(date +%s) * 1000 ))" ;; + esac + + he_e="$(json_escape "$he")" + ti_e="$(json_escape "$ti")" + sid_e="$(json_escape "$session_id")" + + line="{\"v\":1,\"ts_ms\":$ts_ms,\"seq\":0,\"pane_id\":\"$(json_escape "$pane_id")\",\"src\":\"claude\",\"hook_event\":\"$he_e\",\"session_id\":\"$sid_e\",\"title\":\"$ti_e\",\"sev\":\"$sv\"" + if [ -n "$da" ]; then + line="$line,\"data\":$da" + fi + line="$line}" + + printf '%s\n' "$line" >>"$spool_file" 2>/dev/null || \ + log_err "write spool failed: $spool_file" +} + +# SessionStart keeps the original behaviour: validate + atomically write +# the session id file. The session_id rotates over the lifetime of the +# pane (after /clear, /resume, compaction) and the restore path needs the +# LATEST id, not just the first one. +write_session_file() { + sessions_dir="$quil_home/sessions" + if ! mkdir -p "$sessions_dir" 2>/dev/null; then + log_err "mkdir sessions dir failed: $sessions_dir" + return 0 + fi + if [ -z "$session_id" ]; then + log_err "no session_id extracted from stdin" + return 0 + fi + if ! printf '%s' "$session_id" | grep -Eq '^[0-9a-fA-F-]{32,64}$'; then + log_err "session_id rejected as non-uuid: $session_id" + return 0 + fi + out="$sessions_dir/$pane_id.id" + tmp="$(mktemp "$out.XXXXXX" 2>/dev/null)" + if [ -z "$tmp" ]; then + log_err "mktemp failed for $out" + return 0 + fi + if ! printf '%s\n' "$session_id" >"$tmp" 2>/dev/null; then + log_err "write tmp failed: $tmp" + rm -f "$tmp" 2>/dev/null + return 0 + fi + if ! mv "$tmp" "$out" 2>/dev/null; then + log_err "rename failed: $tmp -> $out" + rm -f "$tmp" 2>/dev/null + return 0 + fi +} + +# Route by hook_event_name. Every branch returns to the final exit 0 at the +# bottom of the script. +case "$hook_event" in + SessionStart) + # Session id rotation tracking (original behaviour). + write_session_file + ;; + SessionEnd) + spool "SessionEnd" "Session ended" "info" "" + ;; + UserPromptSubmit) + prompt="$(jget prompt)" + preview="$(printf '%s' "$prompt" | head -c 60)" + prev_e="$(json_escape "$preview")" + title="Working on: $preview" + # Cap title to 200 chars defensively (preview already truncated). + title="$(printf '%s' "$title" | head -c 200)" + title_e="$(json_escape "$title")" + spool "UserPromptSubmit" "$title" "info" "{\"prompt_preview\":\"$prev_e\"}" + ;; + Notification) + # Claude's own notification text — pass through as title. + message="$(jget message)" + msg_t="$(printf '%s' "$message" | head -c 200)" + spool "Notification" "$msg_t" "warning" "" + ;; + PermissionRequest) + tool="$(jget tool_name)" + tool_e="$(json_escape "$tool")" + title="Needs approval: $tool" + title="$(printf '%s' "$title" | head -c 200)" + spool "PermissionRequest" "$title" "warning" "{\"tool\":\"$tool_e\"}" + ;; + Stop) + spool "Stop" "Reply ready" "warning" "" + ;; + PreCompact) + reason="$(jget reason)" + reason_e="$(json_escape "$reason")" + title="Compacting context" + if [ -n "$reason" ]; then + title="$title ($reason)" + title="$(printf '%s' "$title" | head -c 200)" + fi + spool "PreCompact" "$title" "info" "{\"reason\":\"$reason_e\"}" + ;; + PostCompact) + spool "PostCompact" "Compaction complete" "info" "" + ;; + SubagentStart) + agent="$(jget agent_type)" + agent_e="$(json_escape "$agent")" + title="Spawned: $agent" + title="$(printf '%s' "$title" | head -c 200)" + spool "SubagentStart" "$title" "info" "{\"agent_type\":\"$agent_e\"}" + ;; + SubagentStop) + agent="$(jget agent_type)" + agent_e="$(json_escape "$agent")" + title="$agent done" + title="$(printf '%s' "$title" | head -c 200)" + spool "SubagentStop" "$title" "info" "{\"agent_type\":\"$agent_e\"}" + ;; + TaskCreated) + content="$(jget content)" + content_t="$(printf '%s' "$content" | head -c 180)" + content_e="$(json_escape "$content_t")" + title="Task: $content_t" + title="$(printf '%s' "$title" | head -c 200)" + spool "TaskCreated" "$title" "info" "{\"content\":\"$content_e\"}" + ;; + TaskCompleted) + content="$(jget content)" + content_t="$(printf '%s' "$content" | head -c 180)" + content_e="$(json_escape "$content_t")" + title="✓ $content_t" + title="$(printf '%s' "$title" | head -c 200)" + spool "TaskCompleted" "$title" "info" "{\"content\":\"$content_e\"}" + ;; + *) + # Unknown / unhandled event — log a breadcrumb and drop. Not an + # error because Claude can add new events at any time and we want + # graceful forward-compat. + log_err "unhandled hook_event: $hook_event" + ;; +esac -out="$sessions_dir/$pane_id.id" -tmp="$(mktemp "$out.XXXXXX" 2>/dev/null)" -if [ -z "$tmp" ]; then - log_err "mktemp failed for $out" - exit 0 -fi -if ! printf '%s\n' "$session_id" >"$tmp" 2>/dev/null; then - log_err "write tmp failed: $tmp" - rm -f "$tmp" 2>/dev/null - exit 0 -fi -if ! mv "$tmp" "$out" 2>/dev/null; then - log_err "rename failed: $tmp -> $out" - rm -f "$tmp" 2>/dev/null - exit 0 -fi exit 0 diff --git a/internal/opencodehook/scripts/quil-session-tracker.js b/internal/opencodehook/scripts/quil-session-tracker.js index 4f9e500..65ef20d 100644 --- a/internal/opencodehook/scripts/quil-session-tracker.js +++ b/internal/opencodehook/scripts/quil-session-tracker.js @@ -1,13 +1,21 @@ -// Quil OpenCode session tracker — AUTO-GENERATED by the Quil daemon. -// -// Tracks opencode session-id rotation (new session, /new, fork, compaction) -// by writing the current session id to $QUIL_HOME/sessions/opencode-.id. -// The daemon consults that file on pane restore to decide between -// `--session ` (resume exact conversation) and `--continue` (fallback). +// Quil OpenCode session tracker + hook events forwarder. // // Loaded by opencode via OPENCODE_CONFIG_CONTENT when Quil spawns an opencode -// pane. When QUIL_PANE_ID is absent (opencode invoked outside Quil) the -// plugin is a no-op. Safe to delete — Quil recreates it on next daemon start. +// pane. Two responsibilities: +// +// 1. Session-id rotation tracking (original behaviour). Writes the current +// session id to $QUIL_HOME/sessions/opencode-.id; consulted by +// Quil's restore path to decide between --session and --continue. +// +// 2. Hook events forwarding (Phase C). Appends one JSONL line to +// $QUIL_HOME/events/.jsonl per filtered bus event so the +// daemon's hookEventsWatcher can translate them into PaneEvents. +// Filtered to the "default" tier from the hook events plan: +// session.idle/error/compacted, session.status retry-only, +// file.edited (batched 1 s), permission.ask (typed hook). +// +// When QUIL_PANE_ID is absent (opencode invoked outside Quil) the plugin is +// a no-op. Safe to delete — Quil recreates it on next daemon start. import { writeFile, rename, mkdir, unlink, appendFile } from "node:fs/promises"; import { join } from "node:path"; @@ -16,6 +24,24 @@ import { randomUUID } from "node:crypto"; const PANE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const SESSION_ID_RE = /^[0-9a-zA-Z_-]{1,128}$/; +// Wire-format caps mirroring internal/hookevents/types.go. The hook side is +// responsible for truncating before write so the daemon's validator never +// rejects an oversized line. +const MAX_TITLE_BYTES = 200; +const MAX_DATA_VALUE_BYTES = 128; +const MAX_TOTAL_BYTES = 2 * 1024; + +// Per-pane rate budget. 20 sustained events / second with 50 burst. Larger +// than any real-world hook stream but small enough to detect a runaway +// pattern (e.g. a hook plugin in a loop) before the daemon's own limiter +// trips. +const RATE_BUDGET_PER_SEC = 20; +const RATE_BURST = 50; + +// file.edited batching window: opencode emits one file.edited per write; +// batch them in this window and emit a single "Edited N files" event. +const FILE_EDITED_BATCH_MS = 1000; + export default async function quilSessionTracker(_input) { const paneId = process.env.QUIL_PANE_ID || ""; const quilHome = process.env.QUIL_HOME || ""; @@ -28,20 +54,16 @@ export default async function quilSessionTracker(_input) { if (quilHome.includes("\0")) return {}; const sessionsDir = join(quilHome, "sessions"); + const eventsDir = join(quilHome, "events"); const logDir = join(quilHome, "opencodehook"); const logFile = join(logDir, "hook.log"); const target = join(sessionsDir, "opencode-" + paneId + ".id"); - // Cap hook.log at this size so a misbehaving opencode flooding events - // (or a hostile config) cannot fill the disk. We rotate by renaming the - // current log to .1 once over the threshold; older history is discarded. + const spoolFile = join(eventsDir, paneId + ".jsonl"); const LOG_SIZE_CAP_BYTES = 1 * 1024 * 1024; const logLine = async (msg) => { try { await mkdir(logDir, { recursive: true }); - // Best-effort size cap: stat then rotate when over threshold. The - // race between stat and append is fine — worst case we exceed the cap - // by one tick of writes. try { const { stat } = await import("node:fs/promises"); const st = await stat(logFile); @@ -54,15 +76,104 @@ export default async function quilSessionTracker(_input) { } catch (_) { /* never throw from logger */ } }; - // De-dupe by remembering the last id we recorded. opencode fires - // session.updated repeatedly with the same id during a single response; - // without this guard we would do N writes + N log lines per turn. + // Token bucket for spool emission. Refills at RATE_BUDGET_PER_SEC, caps + // at RATE_BURST. Drops with a single warn-log when exhausted; resets on + // refill so a transient burst recovers visibly. + let tokens = RATE_BURST; + let lastRefillMs = Date.now(); + let droppedSinceLog = 0; + + const consumeToken = () => { + const now = Date.now(); + const elapsed = (now - lastRefillMs) / 1000; + if (elapsed > 0) { + tokens = Math.min(RATE_BURST, tokens + elapsed * RATE_BUDGET_PER_SEC); + lastRefillMs = now; + } + if (tokens < 1) { + droppedSinceLog++; + if (droppedSinceLog === 1) { + // Log only the first drop in this exhausted period to avoid spam. + logLine("rate limit exhausted — dropping events"); + } + return false; + } + tokens -= 1; + if (droppedSinceLog > 0) { + logLine("rate limit recovered after " + droppedSinceLog + " drop(s)"); + droppedSinceLog = 0; + } + return true; + }; + + // Sequence counter for the wire-schema seq field. Per-pane monotonic so + // events arriving in the same millisecond can be ordered. + let seq = 0; + + // Truncate a string with an ellipsis. Bytes counted as UTF-8 — opencode + // is permitted to put unicode in payloads (file paths, prompt text). + const truncate = (s, n) => { + if (s == null) return ""; + const buf = Buffer.from(String(s), "utf8"); + if (buf.length <= n) return String(s); + return buf.slice(0, n - 3).toString("utf8") + "…"; + }; + + const truncateData = (data) => { + if (!data) return undefined; + const out = {}; + for (const k of Object.keys(data)) { + const v = data[k] == null ? "" : String(data[k]); + out[k] = truncate(v, MAX_DATA_VALUE_BYTES); + } + return out; + }; + + // Emit one Payload to the spool file. Best-effort; failures land in the + // hook log but never block the opencode runtime. + const spool = async (hookEvent, title, sev, data) => { + if (!consumeToken()) return; + const payload = { + v: 1, + ts_ms: Date.now(), + seq: ++seq, + pane_id: paneId, + src: "opencode", + hook_event: hookEvent, + session_id: lastRecorded || "", + title: truncate(title, MAX_TITLE_BYTES), + sev, + }; + const td = truncateData(data); + if (td && Object.keys(td).length > 0) payload.data = td; + + let line; + try { + line = JSON.stringify(payload); + } catch (e) { + await logLine("stringify payload failed: " + (e && e.message ? e.message : String(e))); + return; + } + if (Buffer.byteLength(line, "utf8") > MAX_TOTAL_BYTES) { + // The data values were each capped, but title + base fields could + // still push us over. Drop with a log; an upstream payload that + // routinely produces > 2 KiB output is a misuse worth surfacing. + await logLine("payload exceeds " + MAX_TOTAL_BYTES + " bytes, dropping " + hookEvent); + return; + } + + try { + await mkdir(eventsDir, { recursive: true }); + await appendFile(spoolFile, line + "\n"); + } catch (e) { + await logLine("write spool failed: " + (e && e.message ? e.message : String(e))); + } + }; + + // Session-id tracking ───────────────────────────────────────────────── let lastRecorded = null; const record = async (sessionId, eventType) => { - // typeof guard: if the bus payload ever surfaces a non-string id (number, - // object) the regex .test() would coerce via String() — narrower to bail - // here so we never write a coerced string to the file. if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) { const shown = sessionId == null ? "(empty)" : String(sessionId).slice(0, 200); await logLine("rejected session_id: " + shown + " (type=" + eventType + ")"); @@ -87,40 +198,131 @@ export default async function quilSessionTracker(_input) { await logLine("cleared (session.deleted)"); }; - // The Hooks contract is returned directly — NOT wrapped under a `hooks:` - // key. See ~/.config/opencode/node_modules/@opencode-ai/plugin/dist/index.d.ts - // (`export interface Hooks { event?: ... }`). The generic `event` hook - // receives every bus event; we filter by `event.type` and extract the - // session id. - // - // Payload shape: opencode 1.14.x runtime emits `properties.sessionID` - // directly on every session.* event. The @opencode-ai/sdk type defs - // (types.gen.d.ts) describe session.created/.updated/.deleted as carrying - // `properties.info: Session` with no top-level sessionID. We accept either - // form to survive bus payload churn between opencode releases — if a - // future version drops the runtime sessionID and reverts to the typed - // shape, tracking continues to work. + // file.edited batching ──────────────────────────────────────────────── + let fileBatch = []; + let fileBatchTimer = null; + + const flushFileBatch = async () => { + fileBatchTimer = null; + const files = fileBatch; + fileBatch = []; + if (files.length === 0) return; + const titlePreview = files.length === 1 + ? "Edited " + files[0] + : "Edited " + files.length + " files"; + await spool("file.edited", titlePreview, "info", { + count: String(files.length), + first: files[0] || "", + }); + }; + + const enqueueFileEdited = (file) => { + if (typeof file !== "string" || file.length === 0) return; + if (fileBatch.length < 50) { + fileBatch.push(file); + } + if (fileBatchTimer == null) { + fileBatchTimer = setTimeout(() => { flushFileBatch().catch(() => {}); }, FILE_EDITED_BATCH_MS); + if (typeof fileBatchTimer.unref === "function") fileBatchTimer.unref(); + } + }; + + // Bus event handler routes session-id tracking + filtered spool emits. + // Note: opencode types describe `properties.info: Session` on session.*, + // but the runtime ALSO surfaces `properties.sessionID` directly. We + // accept both for forward-compat with the bus payload format. return { event: async ({ event }) => { try { const t = event && event.type; - if (!t || !t.startsWith("session.")) return; + if (!t) return; const props = event.properties || {}; - const sid = props.sessionID || (props.info && props.info.id); + + // 1) Session-id rotation tracking (original behaviour). + if (t.startsWith("session.")) { + const sid = props.sessionID || (props.info && props.info.id); + switch (t) { + case "session.created": + case "session.updated": + case "session.idle": + case "session.compacted": + await record(sid, t); + break; + case "session.deleted": + await clear(); + break; + } + } + + // 2) Spool forwarding (Phase C "default" tier). switch (t) { - case "session.created": - case "session.updated": case "session.idle": + await spool("session.idle", "Reply ready", "warning", {}); + break; + + case "session.error": { + let detail = "Session error"; + if (props.error && props.error.message) { + detail = "Session error: " + props.error.message; + } else if (props.error && props.error.type) { + detail = "Session error: " + props.error.type; + } + await spool("session.error", detail, "error", { + error_type: (props.error && props.error.type) || "", + }); + break; + } + case "session.compacted": - await record(sid, t); + await spool("session.compacted", "Compaction complete", "info", {}); + break; + + case "session.status": + if (props.status && props.status.type === "retry") { + const attempt = props.status.attempt || 0; + const msg = props.status.message || "Retrying"; + await spool("session.status.retry", "Retrying API (attempt " + attempt + ")", "warning", { + attempt: String(attempt), + message: msg, + }); + } break; - case "session.deleted": - await clear(); + + case "file.edited": { + const file = props.file || ""; + enqueueFileEdited(file); break; + } } } catch (e) { await logLine("event handler error: " + (e && e.message ? e.message : String(e))); } }, + + // Typed permission.ask hook — fires when opencode needs the user to + // approve a tool use. Surfaces as a "Needs approval: " event. + "permission.ask": async (input, _output) => { + try { + const tool = (input && input.tool) || (input && input.toolName) || "tool"; + const callID = (input && input.callID) || ""; + await spool("permission.ask", "Needs approval: " + tool, "warning", { + tool: String(tool), + call_id: String(callID), + }); + } catch (e) { + await logLine("permission.ask handler error: " + (e && e.message ? e.message : String(e))); + } + }, + + // experimental.session.compacting — emitted before compaction starts. + // We just want the user-facing "Compacting context" card; opencode + // also emits session.compacted at end which fires PostCompact above. + "experimental.session.compacting": async (_input, _output) => { + try { + await spool("experimental.session.compacting", "Compacting context", "info", {}); + } catch (e) { + await logLine("compacting handler error: " + (e && e.message ? e.message : String(e))); + } + }, }; } From a41feab984c1d5a83a7940563b02d2f0766a9f67 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:16:48 +0200 Subject: [PATCH 11/13] =?UTF-8?q?feat(notifications):=20Phase=20D=20?= =?UTF-8?q?=E2=80=94=20config=20knobs,=20docs,=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing surface for the hook events tier per the plan's Phase D section. No new code paths in the daemon — just plumbing the config value to the hook scripts so users can opt into verbose mode or turn hook events off entirely without editing the embedded scripts. internal/config/config.go: - New HookNotificationsConfig{Claude, OpenCode string} nested under NotificationConfig.Hooks (TOML key `notification.hooks`). Default values "default" set in Default() so a fresh install gets the v1 tier from Phase C automatically. internal/daemon/daemon.go: - claudeHookSpawnPrep and opencodeSpawnPrep gain a hookMode parameter threaded from d.cfg.Notification.Hooks.Claude / d.cfg.Notification.Hooks.OpenCode. Both pass QUIL_HOOK_MODE= in the PTY env (alongside the existing QUIL_PANE_ID / QUIL_HOME). Empty mode resolves to "default" so a user with no [notification] block still gets the v1 tier. - Tests updated: TestClaudeHookSpawnPrep / TestOpencodeSpawnPrep now assert the QUIL_HOOK_MODE env var is present and equal to "default" (the tests' fixed input mode). docs/configuration.md: - Full [notification.hooks] subsection documenting `claude` and `opencode` knobs with the three tier values (`default` / `verbose` / `off`) and what each forwards. Hook events JSONL spool path mentioned so users can verify the daemon is consuming them. - Default config block adds the [notification.hooks] entries. .claude/CLAUDE.md: - New `internal/hookevents/` bullet explaining the wire schema, Spool / Ingester / rate limiter / coalescer responsibilities, the HookHealthy fallback, and the JSONL spool lifecycle. - `internal/claudehook/` and `internal/opencodehook/` bullets updated to mention the multi-event extension, the QUIL_HOOK_MODE env, and the per-pane rate budget on the OpenCode side. Phases B + C + D complete. The full hook-driven notification surface is in place — Claude PermissionRequest and Stop, OpenCode permission.ask and session.idle, et al — and runs through the daemon's coalesce + rate limit + aggregation pipeline before the IPC fan-out. All green under go test ./... and -race. --- .claude/CLAUDE.md | 5 +++-- docs/configuration.md | 15 +++++++++++++ internal/config/config.go | 20 +++++++++++++++-- internal/daemon/daemon.go | 22 ++++++++++++++----- internal/daemon/spawn_args_test.go | 35 ++++++++++++++++++++---------- 5 files changed, 77 insertions(+), 20 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3f1cd93..34f68c3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -26,8 +26,9 @@ Client-daemon model: - `internal/persist/` — Atomic workspace/buffer persistence (JSON snapshots, binary ghost buffers) - `internal/pty/` — Cross-platform PTY (build tags: `linux || darwin || freebsd`, `windows`) - `internal/shellinit/` — Automatic OSC 7 + OSC 133 shell integration (embedded init scripts, `//go:embed`) -- `internal/claudehook/` — Claude Code SessionStart hook installer + reader (`//go:embed scripts/`, atomic temp+rename, `ValidateQuilDir` rejects shell-unsafe paths). Writes `quil-session-hook.sh` / `.ps1` into `config.ClaudeHookDir()` at daemon start; `ReadPersistedSessionID` consults `config.SessionsDir()/.id` on restore. Mirrors `internal/shellinit/` install pattern -- `internal/opencodehook/` — OpenCode session-id tracker plugin installer + reader. Same shape as `claudehook` but the embedded artifact is a JS plugin (`scripts/quil-session-tracker.js`) loaded by opencode at spawn via `OPENCODE_CONFIG_CONTENT='{"plugin":[""]}'`. Plugin file lives at `$QUIL_HOME/opencodehook/quil-session-tracker.js`; per-pane ids land at `$QUIL_HOME/sessions/opencode-.id` (prefix avoids collision with claudehook's `.id`). `OPENCODE_CONFIG_CONTENT` MERGES with the user's existing opencode config so user plugins/agents/modes survive (verified against opencode 1.14.x). `ReadPersistedSessionID` `Lstat`-rejects symlinks. PTY env carries `QUIL_PANE_ID`, `QUIL_HOME`, and the inline config content per opencode spawn +- `internal/claudehook/` — Claude Code multi-event hook installer + reader (`//go:embed scripts/`, atomic temp+rename, `ValidateQuilDir` rejects shell-unsafe paths). `BuildSettingsJSON` registers Quil's hook command under 12 Claude events (SessionStart for session-id tracking + 11 forwarded to the JSONL spool: SessionEnd, UserPromptSubmit, Notification, PermissionRequest, Stop, PreCompact, PostCompact, SubagentStart/Stop, TaskCreated/TaskCompleted). The embedded `.sh`/`.ps1` branches on `hook_event_name` and either writes the session id file (SessionStart) or appends a hookevents.Payload JSONL line. PTY env carries `QUIL_PANE_ID` + `QUIL_HOOK_MODE` (`"default"|"verbose"|"off"`). `ReadPersistedSessionID` consults `config.SessionsDir()/.id` on restore. Mirrors `internal/shellinit/` install pattern +- `internal/opencodehook/` — OpenCode session-id tracker + hook events forwarder. Embedded JS plugin (`scripts/quil-session-tracker.js`) loaded by opencode at spawn via `OPENCODE_CONFIG_CONTENT='{"plugin":[""]}'`. Plugin file lives at `$QUIL_HOME/opencodehook/quil-session-tracker.js`. Two responsibilities: (1) session-id rotation tracking — per-pane ids at `$QUIL_HOME/sessions/opencode-.id` (prefix avoids collision with claudehook's `.id`); (2) hook events forwarding — filtered bus subscriptions (session.idle/error/compacted, session.status retry-only, file.edited batched 1 s) + typed handlers (permission.ask, experimental.session.compacting) append hookevents.Payload JSONL lines to `$QUIL_HOME/events/.jsonl`. Per-pane token bucket (20/s sustained, 50 burst) drops with single warn-log when exhausted. UTF-8-aware truncation respects hook-side caps. `OPENCODE_CONFIG_CONTENT` MERGES with the user's existing opencode config so user plugins/agents/modes survive (verified against opencode 1.14.x). `ReadPersistedSessionID` `Lstat`-rejects symlinks. PTY env carries `QUIL_PANE_ID`, `QUIL_HOME`, `QUIL_HOOK_MODE`, and the inline config content per opencode spawn +- `internal/hookevents/` — Hook-driven notifications pipeline. `Payload` wire schema (v=1, ts_ms, seq, pane_id, src=claude|opencode, hook_event, session_id, title, sev, data). `Spool` reads JSONL files at `$QUIL_HOME/events/.jsonl` appended by the claude .sh / opencode .js hook producers; polled by `daemon.hookEventsWatcher` every 200 ms, tracks per-file byte offset, skips trailing partial lines. `Ingester` per-pane sliding-window rate limit (100/2s — on trip emits synthetic `internal.event_storm` then drops 10 s) + per-(paneID, hook_event) 50 ms debounce coalescer (last-wins with `data["coalesced"]` burst count). Daemon-side translation `emitHookEvent(Payload) → PaneEvent` enriches with TabID/PaneName, sets `Pane.HookHealthy` + `Pane.LastHookEventAt`, routes through existing `emitEvent` (mute + aggregation + broadcast). `checkIdlePanes.shouldFire` skips the legacy idle excerpt when `HookHealthy && now-LastHookEventAt < 30 s` — fallback to legacy idle if hooks never load (plugin throws at init, settings JSON malformed). Spool init truncates stale files on daemon start; `DestroyPane` unlinks the spool file. Wire caps enforced hook-side (title ≤ 200, data value ≤ 128, total ≤ 2 KiB); daemon's PaneEvent caps (4 KiB / 1 KiB) are the outer backstop. Tier knob `[notification.hooks] claude = "default"|"verbose"|"off"` flows to scripts via `QUIL_HOOK_MODE` env at pane spawn - `internal/plugin/` — Pane plugin system (registry, built-ins, TOML loading, scraper) - `internal/clipboard/` — Platform-native clipboard read/write (Win32 API, pbpaste/pbcopy, xclip/xsel) - `internal/tui/` — Bubble Tea model, tabs, panes, layout tree, styles, text selection, notification sidebar diff --git a/docs/configuration.md b/docs/configuration.md index 448c6ec..7b7be53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,10 @@ highlight_duration = "10s" # border flash duration when AI touches a pane sidebar_width = 30 # columns reserved for the notification sidebar max_events = 200 # ring-buffer cap (per daemon, both sidebar and MCP) +[notification.hooks] +claude = "default" # "default" | "verbose" | "off" +opencode = "default" # same + [keybindings] quit = "ctrl+q" new_tab = "ctrl+t" @@ -130,6 +134,17 @@ The "ghost buffer" is the rendered preview Quil shows immediately on reconnect, | `sidebar_width` | int | `30` | Columns reserved for the notification sidebar when toggled (`Alt+N`). Reducing this gives more room to panes; values below ~25 truncate event titles and excerpts heavily. | | `max_events` | int | `200` | Ring-buffer cap for the daemon's notification queue. The sidebar and MCP `get_notifications` both read from this queue. Each event is bounded to ≤ 4 KiB `Message` + ≤ 1 KiB per `Data` value (`_quil_truncated` flag set when truncated). | +### `[notification.hooks]` + +Hook-driven notifications surface structured events from Claude Code and OpenCode (permission asks, retries, "reply ready", file edits, …) instead of guessing from the PTY byte stream. The daemon writes the resolved tier to the hook script's environment via `QUIL_HOOK_MODE` at pane spawn so the script can branch on it. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `claude` | string | `"default"` | Tier for Claude Code panes. `"default"` forwards SessionEnd, UserPromptSubmit, Notification, PermissionRequest, Stop, PreCompact, PostCompact, SubagentStart/Stop, TaskCreated/TaskCompleted. `"verbose"` additionally forwards PreToolUse/PostToolUse (one card per tool call — useful for debugging, noisy in normal use). `"off"` disables hook event forwarding entirely; Quil falls back to the legacy PTY-byte idle heuristic. | +| `opencode` | string | `"default"` | Tier for OpenCode panes. `"default"` forwards session.idle/error/compacted, session.status retry only, file.edited batched 1 s, permission.ask, experimental.session.compacting. `"verbose"` adds tool.execute.before/after. `"off"` disables hook event forwarding. | + +The hook events flow through a JSONL spool (`~/.quil/events/.jsonl`) that the daemon polls every 200 ms. Truncated on daemon start (no replay of stale events); deleted on pane destroy. + ## `[keybindings]` Every binding accepts a Bubble Tea key string. Common forms: diff --git a/internal/config/config.go b/internal/config/config.go index 9fc3885..8cbe5dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,8 +21,20 @@ type Config struct { } type NotificationConfig struct { - SidebarWidth int `toml:"sidebar_width"` // default 30 - MaxEvents int `toml:"max_events"` // default 50 + SidebarWidth int `toml:"sidebar_width"` // default 30 + MaxEvents int `toml:"max_events"` // default 200 + Hooks HookNotificationsConfig `toml:"hooks"` +} + +// HookNotificationsConfig controls which hook-driven events get spool-emitted +// per source. Tier values are "default" / "verbose" / "off". Daemon passes +// the resolved value to the hook scripts via the QUIL_HOOK_MODE env var at +// pane spawn so the script can branch on it (default → forward the v1 tier; +// verbose → also forward tool-use + pre/post events; off → no spool writes +// at all). Unset = "default" downstream. +type HookNotificationsConfig struct { + Claude string `toml:"claude"` + OpenCode string `toml:"opencode"` } type MCPConfig struct { @@ -146,6 +158,10 @@ func Default() Config { Notification: NotificationConfig{ SidebarWidth: 30, MaxEvents: 200, + Hooks: HookNotificationsConfig{ + Claude: "default", + OpenCode: "default", + }, }, Keybindings: KeybindingsConfig{ Quit: "ctrl+q", diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 97f08e9..77e696a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1387,7 +1387,7 @@ var opencodeHookScriptStatFn = func(path string) error { // entries against its own CWD, not the daemon's. With `prompts_cwd = true` // the child CWD is user-chosen and may differ from where the daemon was // launched, so a relative quilDir would silently break tracking. -func opencodeSpawnPrep(quilDir, paneID string) []string { +func opencodeSpawnPrep(quilDir, paneID, hookMode string) []string { absQuilDir, err := filepath.Abs(quilDir) if err != nil { log.Printf("warning: pane %s: absolutize quilDir %q: %v — session-id rotation tracking disabled", paneID, quilDir, err) @@ -1403,9 +1403,14 @@ func opencodeSpawnPrep(quilDir, paneID string) []string { log.Printf("warning: pane %s: build opencode config content: %v — session-id rotation tracking disabled", paneID, err) return nil } + mode := hookMode + if mode == "" { + mode = "default" + } return []string{ "QUIL_PANE_ID=" + paneID, "QUIL_HOME=" + absQuilDir, + "QUIL_HOOK_MODE=" + mode, "OPENCODE_CONFIG_CONTENT=" + cfg, } } @@ -1417,7 +1422,7 @@ func opencodeSpawnPrep(quilDir, paneID string) []string { // pre-feature behaviour rather than failing the whole spawn. Logs a warning // if userArgs already contain --settings; Claude treats later wins, so our // prepend silently overrides the user's value. -func claudeHookSpawnPrep(quilDir, paneID string, userArgs []string) (prefix, env []string) { +func claudeHookSpawnPrep(quilDir, paneID, hookMode string, userArgs []string) (prefix, env []string) { scriptPath := claudehook.ScriptPath(quilDir) if err := claudeHookScriptStatFn(scriptPath); err != nil { log.Printf("warning: pane %s: claude hook script unavailable (%s): %v — session-id rotation tracking disabled", paneID, scriptPath, err) @@ -1434,7 +1439,14 @@ func claudeHookSpawnPrep(quilDir, paneID string, userArgs []string) (prefix, env break } } - return []string{"--settings", js}, []string{"QUIL_PANE_ID=" + paneID} + mode := hookMode + if mode == "" { + mode = "default" + } + return []string{"--settings", js}, []string{ + "QUIL_PANE_ID=" + paneID, + "QUIL_HOOK_MODE=" + mode, + } } // escapeClaudeCWD mirrors Claude Code's on-disk naming for per-project @@ -1706,13 +1718,13 @@ func (d *Daemon) spawnPane(pane *Pane, ptySession apty.Session, restoring bool) envVars := append([]string{}, p.Command.Env...) switch p.Name { case "claude-code": - settingsArgs, hookEnv := claudeHookSpawnPrep(config.QuilDir(), pane.ID, args) + settingsArgs, hookEnv := claudeHookSpawnPrep(config.QuilDir(), pane.ID, d.cfg.Notification.Hooks.Claude, args) if len(settingsArgs) > 0 { args = append(settingsArgs, args...) } envVars = append(envVars, hookEnv...) case "opencode": - envVars = append(envVars, opencodeSpawnPrep(config.QuilDir(), pane.ID)...) + envVars = append(envVars, opencodeSpawnPrep(config.QuilDir(), pane.ID, d.cfg.Notification.Hooks.OpenCode)...) } if len(envVars) > 0 { diff --git a/internal/daemon/spawn_args_test.go b/internal/daemon/spawn_args_test.go index c9f0e16..e76fc05 100644 --- a/internal/daemon/spawn_args_test.go +++ b/internal/daemon/spawn_args_test.go @@ -528,7 +528,7 @@ func TestClaudeHookSpawnPrep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { claudeHookScriptStatFn = func(string) error { return tt.statErr } - prefix, env := claudeHookSpawnPrep("/tmp/quil", tt.paneID, tt.userArgs) + prefix, env := claudeHookSpawnPrep("/tmp/quil", tt.paneID, "default", tt.userArgs) if tt.wantPrefix { if len(prefix) != 2 || prefix[0] != "--settings" { t.Errorf("prefix = %v, want [--settings ...]", prefix) @@ -546,8 +546,18 @@ func TestClaudeHookSpawnPrep(t *testing.T) { t.Errorf("env = %v, want nil", env) } } else { - if len(env) != 1 || env[0] != tt.wantEnvVar { - t.Errorf("env = %v, want [%q]", env, tt.wantEnvVar) + // Phase D: claudeHookSpawnPrep now returns QUIL_PANE_ID + // AND QUIL_HOOK_MODE so the hook script can branch on + // "default" / "verbose" / "off". The pane-id var is at + // index 0 (unchanged); the new hook-mode var is at 1. + if len(env) != 2 { + t.Errorf("env = %v, want 2 entries (pane id + hook mode)", env) + } + if env[0] != tt.wantEnvVar { + t.Errorf("env[0] = %q, want %q", env[0], tt.wantEnvVar) + } + if len(env) > 1 && env[1] != "QUIL_HOOK_MODE=default" { + t.Errorf("env[1] = %q, want QUIL_HOOK_MODE=default", env[1]) } } }) @@ -723,10 +733,10 @@ func TestOpencodeSpawnPrep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opencodeHookScriptStatFn = func(string) error { return tt.statErr } - env := opencodeSpawnPrep("/tmp/quil", tt.paneID) + env := opencodeSpawnPrep("/tmp/quil", tt.paneID, "default") if tt.wantEnv { - if len(env) != 3 { - t.Fatalf("env = %v, want 3 entries", env) + if len(env) != 4 { + t.Fatalf("env = %v, want 4 entries (pane, home, mode, config)", env) } if env[0] != tt.wantPaneEnv { t.Errorf("env[0] = %q, want %q", env[0], tt.wantPaneEnv) @@ -734,16 +744,19 @@ func TestOpencodeSpawnPrep(t *testing.T) { if env[1] != "QUIL_HOME=/tmp/quil" { t.Errorf("env[1] = %q, want QUIL_HOME=/tmp/quil", env[1]) } - if !strings.HasPrefix(env[2], "OPENCODE_CONFIG_CONTENT=") { - t.Errorf("env[2] = %q, want OPENCODE_CONFIG_CONTENT=... prefix", env[2]) + if env[2] != "QUIL_HOOK_MODE=default" { + t.Errorf("env[2] = %q, want QUIL_HOOK_MODE=default", env[2]) + } + if !strings.HasPrefix(env[3], "OPENCODE_CONFIG_CONTENT=") { + t.Errorf("env[3] = %q, want OPENCODE_CONFIG_CONTENT=... prefix", env[3]) } - if !strings.Contains(env[2], "quil-session-tracker.js") { - t.Errorf("env[2] missing plugin filename: %s", env[2]) + if !strings.Contains(env[3], "quil-session-tracker.js") { + t.Errorf("env[3] missing plugin filename: %s", env[3]) } // Round-trip-parse the inline config so a future regression in // configContentSchema's wire format gets caught here, not by // opencode silently ignoring the plugin entry at load time. - jsonPart := strings.TrimPrefix(env[2], "OPENCODE_CONFIG_CONTENT=") + jsonPart := strings.TrimPrefix(env[3], "OPENCODE_CONFIG_CONTENT=") var parsed struct { Plugin []string `json:"plugin"` } From 8294126ffdc3b3490da5b87a94843ed77dba587c Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:47:18 +0200 Subject: [PATCH 12/13] fix(notifications): address Phase B+C+D review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All Critical / High / Medium / Low items from the final review, plus the test-coverage gaps QA flagged. Every fix has a one-liner in the review notes for traceability. ## Critical (1) C1 Ingester pending timer leak past pane destroy internal/hookevents/ingest.go — new Ingester.Cancel(paneID) stops every AfterFunc timer keyed by paneID and removes the per-pane rate bookkeeping. Wired into both handleDestroyPane / handleDestroyPaneReq paths in daemon.go so a destroyed pane cannot fire one final stale event ~50 ms later via the coalescer's natural window close. ## High (4) H1 Path traversal via Spool.Cleanup(paneID) daemon.go — both destroy IPC handlers now validate payload.PaneID with isValidHexID before any FS-touching call. Defense-in-depth: spool.go safePaneID rejects /, \, .., NUL etc and Cleanup uses filepath.Clean + HasPrefix to ensure the resolved path stays under the spool dir. New tests TestSafePaneID and TestSpool_Cleanup_RejectsTraversalPaneID cover the guard. H1 (CR) FlushAll shutdown race ingest.go — Ingester.closed flag set by FlushAll; subsequent Submit is a no-op. FlushAll now Stops every pending timer before draining so AfterFunc cannot fire after FlushAll returns. New tests TestIngester_Submit_AfterFlushAll_IsNoOp and TestIngester_FlushAll_StopsPendingTimers pin the invariants. H2 QUIL_HOOK_MODE was a dead knob All three hook script producers (sh, ps1, js) now read $QUIL_HOOK_MODE and short-circuit the spool emission when mode == "off". Daemon-side backstop in emitHookEvent drops events when the resolved d.cfg.Notification.Hooks. is "off" — covers the case where the script-side gate didn't fire (older script on disk, env stripped). Storm diagnostics always pass. H3 First hook event being storm flips HookHealthy=true daemon.go — emitHookEvent only sets pane.HookHealthy / LastHookEventAt when p.HookEvent != hookevents.EventStorm. The rate limiter's own self-emitted storm payload would otherwise mark a pane "hook-healthy" exactly when real events have stopped flowing, silencing the legacy idle excerpt fallback during the 10 s penalty window. ## Medium (10) Sec M1 Cross-pane spoofing via payload.PaneID spool.go parseAndValidate now drops payloads whose pane_id field does not match the filename-derived paneID. A compromised plugin in pane A can no longer write events attributed to pane B. Sec M2 Unbounded spool growth spool.go new rotation: when readPaneFile observes size == offset (fully drained) AND size >= 16 MiB, truncate the file and reset the offset. Rotation runs only on idle ticks so it never collides with in-flight reads. CR M2 io.ReadAll OOM on multi-MB single line spool.go switches from io.ReadAll + bytes.Split to bufio.Reader. ReadBytes('\n') with per-line size enforcement. A 100 MB single line from a misbehaving producer no longer allocates into memory; it's detected at MaxTotalBytes and dropped after advancing past it. CR M4 PowerShell JsonEscape backslash + C0 control bytes quil-session-hook.ps1 — backslash replacement now produces exactly two chars (the prior pattern produced four backslashes per input backslash, breaking every Windows path). Added a [regex]::Replace pass that converts [\x00-\x1f] to \uXXXX so ESC and other ANSI control bytes from Claude tool output don't break the JSON line. CR M5 OpenCode JS clock-jump-backward quil-session-tracker.js — consumeToken clamps elapsed to ≥ 0 and resets the refill anchor on backward jumps. Without this, an NTP correction would freeze the rate limiter until the clock caught up. CR M6 OpenCode JS UTF-8 mid-codepoint truncation quil-session-tracker.js — truncate walks backward over UTF-8 continuation bytes (0x80-0xBF) so the cut lands on a codepoint boundary. The trailing "…" replaces clean truncated content instead of polluting it with U+FFFD. CR M7 Unbounded timestamp on hook payload daemon.go emitHookEvent — clamps p.TsMs to within ±1 hour of the daemon's clock; events with skewed timestamps fall back to time.Now. Rules #2 + #3 + #5 Observability + error wrapping spool.go — parsePayloads warns are sampled (1 in N per pane) to prevent log flooding from a misbehaving producer. Unmarshal errors log only the byte size, never err.Error() which could fragment user prompt content into quild.log. Spool.Init returns wrapped errors with package context. Rules #4 docs/features.md updates features.md Notification center section now describes hook-driven events, the tier knob (default / verbose / off), the flow diagram, and the Alt+M mute keybinding. ## Low + polish L1 forwardedHookEvents duplicate detection claudehook_test.go — TestForwardedHookEvents_NoDuplicates catches a silently-deduped slice entry at build time. L2 FlushAll doc comment ingest.go — doc comment now correctly notes it's called from hookEventsWatcher, not Daemon.Stop. L3 emitHookEvent Data map double-allocation daemon.go — single allocation with len(p.Data)+2 capacity. L5 Dead awk truncate() function quil-session-hook.sh removed; replaced inline truncation guidance. json_escape simplified to a portable tr+sed pipeline (no GNU sed `:a;N;$!ba` slurp pattern; works on busybox / macOS sed). Sec L2 PowerShell C0 control bytes Same fix as CR M4 above. EventStorm constant ingest.go exports the literal "internal.event_storm" string so both producer (stormPayload) and consumer (emitHookEvent) reference the same constant. ## Test additions (7 new test functions) - TestSafePaneID (table-driven, 9 cases) - TestSpool_Cleanup_RejectsTraversalPaneID - TestIngester_Cancel_DropsPendingForPane - TestIngester_Cancel_DoesNotAffectOtherPanes - TestIngester_Cancel_Idempotent - TestIngester_Submit_AfterFlushAll_IsNoOp - TestIngester_FlushAll_StopsPendingTimers - TestForwardedHookEvents_NoDuplicates All green under go test ./... and go test -race ./... --- docs/features.md | 21 +- internal/claudehook/claudehook_test.go | 15 ++ .../claudehook/scripts/quil-session-hook.ps1 | 31 ++- .../claudehook/scripts/quil-session-hook.sh | 51 +++- internal/daemon/daemon.go | 100 +++++-- internal/hookevents/cancel_test.go | 136 ++++++++++ internal/hookevents/ingest.go | 73 +++++- internal/hookevents/safepane_test.go | 75 ++++++ internal/hookevents/spool.go | 245 ++++++++++++++---- .../scripts/quil-session-tracker.js | 36 ++- 10 files changed, 682 insertions(+), 101 deletions(-) create mode 100644 internal/hookevents/cancel_test.go create mode 100644 internal/hookevents/safepane_test.go diff --git a/docs/features.md b/docs/features.md index 4485be0..d4ed46f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -167,15 +167,32 @@ A non-modal sidebar surfaces: - Process exits (any pane) - OSC 133 command-completion events (shell panes) - Bell characters (30 s cooldown to avoid storming) -- Smart-idle pattern matches (per-plugin `[[notification_handlers]]` regex) +- Smart-idle pattern matches (per-plugin `[[idle_handlers]]` regex) +- **Hook-driven events from Claude Code and OpenCode** — structured events forwarded directly from the AI tool (permission requests, "reply ready", session errors, file edits, etc.) instead of guessed from the PTY byte stream. See `[notification.hooks]` in [configuration.md](configuration.md#notificationhooks) for the tier knob. + +Hook-driven events flow: + +``` +hook fires (claude .sh / opencode .js) + → writes one JSONL line to ~/.quil/events/.jsonl + → daemon polls every 200 ms (rate-limited to 100/2s per pane, coalesced 50 ms per event-type) + → translated to PaneEvent and routed through the same broadcast pipeline +``` + +Tier values (per source — Claude and OpenCode are configured independently): + +- `default` (the v1 set): Claude `SessionEnd`, `UserPromptSubmit`, `Notification`, `PermissionRequest`, `Stop`, `PreCompact`/`PostCompact`, `SubagentStart/Stop`, `TaskCreated/Completed`; OpenCode `permission.ask`, `experimental.session.compacting`, plus filtered bus events (`session.idle/error/compacted`, `session.status` retry-only, `file.edited` batched 1 s). +- `verbose` (currently identical to `default` — placeholder for future tier-2 events like Claude `PreToolUse`/`PostToolUse`). +- `off` disables forwarding entirely; the legacy PTY-byte idle heuristic kicks back in as the fallback notification surface. | Action | Binding | |---|---| | Toggle sidebar | `Alt+N` (3-state: hidden → visible+unfocused → visible+focused → hidden) | | Focus sidebar | `F3` | | Pane back-button (browser-style) | `Alt+Backspace` | +| Mute / unmute active pane | `Alt+M` | -External AI agents can subscribe via MCP — `get_notifications` (non-blocking) and `watch_notifications` (blocking, up to 5 min) replace polling. See [MCP](mcp.md#event-observation). +External AI agents can subscribe via MCP — `get_notifications` (non-blocking), `watch_notifications` (blocking, up to 5 min) and `dismiss_notifications` (ack from agent side) replace polling. See [MCP](mcp.md#event-observation). ### Memory reporting diff --git a/internal/claudehook/claudehook_test.go b/internal/claudehook/claudehook_test.go index d012db3..87b7472 100644 --- a/internal/claudehook/claudehook_test.go +++ b/internal/claudehook/claudehook_test.go @@ -134,6 +134,21 @@ func TestBuildSettingsJSON_RegistersAllForwardedEvents(t *testing.T) { } } +// TestForwardedHookEvents_NoDuplicates guards a silent footgun: the +// BuildSettingsJSON loop overwrites by name in the Hooks map, so a +// duplicate entry would dedupe without warning. This test catches that at +// build time rather than at the first Claude session. +func TestForwardedHookEvents_NoDuplicates(t *testing.T) { + t.Parallel() + seen := make(map[string]bool) + for _, name := range forwardedHookEvents { + if seen[name] { + t.Errorf("duplicate entry in forwardedHookEvents: %q", name) + } + seen[name] = true + } +} + func TestBuildSettingsJSON_EscapesQuotesInCommand(t *testing.T) { t.Parallel() js, err := BuildSettingsJSON(`sh "/tmp/with quotes/hook.sh"`) diff --git a/internal/claudehook/scripts/quil-session-hook.ps1 b/internal/claudehook/scripts/quil-session-hook.ps1 index fc21a37..b81688e 100644 --- a/internal/claudehook/scripts/quil-session-hook.ps1 +++ b/internal/claudehook/scripts/quil-session-hook.ps1 @@ -31,6 +31,9 @@ function Write-HookErr([string]$msg) { } catch {} } +$mode = $env:QUIL_HOOK_MODE +if (-not $mode) { $mode = 'default' } + $payload = [Console]::In.ReadToEnd() # Try the structured ConvertFrom-Json first; fall back to regex extraction @@ -61,17 +64,43 @@ function Truncate([string]$s, [int]$n) { } # Escape a string for embedding in a JSON string literal. +# +# PowerShell -replace is a .NET Regex.Replace under the hood. In the +# replacement string, `$` is the substitution sigil but backslash is +# literal. So to emit JSON's `\\` (two chars) the replacement value must +# be the LITERAL two-character string `\\`, expressed in PowerShell as +# the single-quoted `'\\'` — which is exactly two characters. +# +# Order matters: backslash is escaped FIRST so subsequent replacements' +# inserted `\` characters are not re-escaped. +# +# C0 control characters (0x00–0x1F) other than `\n`/`\r`/`\t` are +# escaped as `\uXXXX` so the resulting JSON string literal is valid; +# without this, a Claude payload containing e.g. `\x1b` (ESC, common in +# ANSI-colored tool output) would produce a line the daemon's strict +# json.Unmarshal rejects, silently dropping the event. function JsonEscape([string]$s) { if ($null -eq $s) { return '' } - $s = $s -replace '\\', '\\\\' + $s = $s -replace '\\', '\\' $s = $s -replace '"', '\"' $s = $s -replace "`n", '\n' $s = $s -replace "`r", '\r' $s = $s -replace "`t", '\t' + # Catch-all for remaining C0 control bytes [\x00-\x1F]. Without this a + # Claude payload containing e.g. ESC (\x1B from ANSI-colored tool + # output) would produce a line the daemon's strict json.Unmarshal + # rejects, silently dropping the event. Match evaluator formats the + # matched character as JSON's \u00XX hex escape. + $s = [regex]::Replace($s, '[\x00-\x1f]', { + param($m) + '\u{0:x4}' -f [int][char]$m.Value + }) return $s } function Spool-Event([string]$he, [string]$title, [string]$sev, [string]$dataJson) { + # Off-mode short-circuit (see .sh for design notes). + if ($mode -eq 'off') { return } $eventsDir = Join-Path $quilHome 'events' try { New-Item -ItemType Directory -Path $eventsDir -Force | Out-Null } catch { Write-HookErr ("mkdir events dir failed: {0}" -f $eventsDir) diff --git a/internal/claudehook/scripts/quil-session-hook.sh b/internal/claudehook/scripts/quil-session-hook.sh index 04a78ed..431dc1f 100644 --- a/internal/claudehook/scripts/quil-session-hook.sh +++ b/internal/claudehook/scripts/quil-session-hook.sh @@ -41,6 +41,12 @@ log_err() { >>"$log_file" 2>/dev/null || true } +# QUIL_HOOK_MODE gates the spool emission tier. The daemon passes it at pane +# spawn from `[notification.hooks] claude` in config.toml. SessionStart +# always writes the session id file regardless of mode (it is the +# infrastructure for resume, not a notification). +mode="${QUIL_HOOK_MODE:-default}" + # Stdin is consumed exactly once; everything else reads from $payload. payload="$(cat)" @@ -61,29 +67,46 @@ jget() { hook_event="$(jget hook_event_name)" session_id="$(jget session_id)" -# Truncate to the wire-schema limits before any composition. Claude's -# inputs (prompt, tool args, etc.) can be arbitrarily large; we cap at -# 256 chars for any preview field and let the daemon enforce the 1 KiB -# Data value backstop. Title cap is 200. -truncate() { - awk -v n="$2" 'BEGIN { v=ARGV[1]; if (length(v) <= n) print v; else print substr(v, 1, n-1) "…"; }' "$1" 2>/dev/null -} - # json_escape escapes a string for embedding inside a JSON string literal. -# Handles backslash, double-quote, newline, carriage return, and tab; other -# control bytes pass through (the daemon's json.Unmarshal will reject them -# and the line will be dropped — acceptable since this is best-effort -# escaping in a shell script). +# Handles backslash, double-quote, tab, and converts any C0 control byte +# (including \n and \r) to its \uXXXX form so the resulting JSON line is +# valid even when Claude payloads contain raw control characters (ESC from +# ANSI-colored tool output, embedded newlines in multi-line prompts, etc). +# +# We use `printf | tr` to first replace each control byte with a printable +# marker, then sed to substitute the JSON escape — this sidesteps the +# GNU-vs-BSD sed slurp-into-pattern-space portability question. Less +# elegant than a single sed, but portable to busybox / dash / macOS sed. +# +# Note on byte truncation: callers feed json_escape strings that have +# already been bounded by `head -c N`. head -c counts BYTES not characters, +# so non-ASCII content can land mid-codepoint at the cut. The daemon's +# strict UTF-8 json.Unmarshal then rejects the line and the event is +# silently dropped. Acceptable v1 limitation for Claude (commands and +# tool names are ASCII); user prompts containing non-ASCII may produce +# events with truncated previews replaced by … in the daemon side cap. json_escape() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e ':a' -e 'N' -e '$!ba' \ - -e 's/\n/\\n/g' -e 's/\r/\\r/g' -e 's/\t/\\t/g' + # Replace newlines/CR/tab with a single marker each, then escape via + # sed. Use `printf` to avoid `echo -e` portability differences. + printf '%s' "$1" | \ + tr '\n' '\1' | tr '\r' '\2' | tr '\t' '\3' | \ + sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \ + -e 's/\x01/\\n/g' -e 's/\x02/\\r/g' -e 's/\x03/\\t/g' } # Append a JSONL event line to the spool. Single write(2) keeps it atomic # under PIPE_BUF on Unix (the schema cap of 2 KiB stays well under the # typical 4 KiB PIPE_BUF). Args: 1=hook_event, 2=title, 3=severity, # 4=data_json (already a {"k":"v",...} string, may be empty for none). +# +# Off-mode short-circuit: when QUIL_HOOK_MODE=off, drop everything. When +# the mode is "default" (the standard tier — see forwardedHookEvents in +# claudehook.go for what claude registers under) or "verbose" (a superset +# claude would have to register additional hooks to populate), the spool +# write proceeds. The router below dispatches the default tier only; +# extending to verbose requires extending forwardedHookEvents. spool() { + [ "$mode" = "off" ] && return 0 he="$1" ti="$2" sv="$3" diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 77e696a..5278c86 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -951,6 +951,14 @@ func (d *Daemon) handleDestroyPane(msg *ipc.Message) { if err := msg.DecodePayload(&payload); err != nil { return } + // Defense-in-depth: reject malformed PaneID at the IPC ingress so a + // crafted payload (e.g. PaneID = "../../tmp/target") cannot escape the + // spool directory in Spool.Cleanup or any other paneID-keyed FS path. + // Production paneIDs are uuid-derived hex; anything else is invalid. + if !isValidHexID(payload.PaneID, "pane-") { + log.Printf("handleDestroyPane: rejected malformed PaneID %q", payload.PaneID) + return + } // Capture tab ID before destroying the pane var tabID string @@ -959,12 +967,17 @@ func (d *Daemon) handleDestroyPane(msg *ipc.Message) { } log.Printf("pane destroy: %s (tab=%s)", payload.PaneID, tabID) - // Tear down the pane's hook event spool file before destroying the - // pane itself — the watcher's next tick must not pick up stale lines - // from a destroyed pane. + // Tear down the pane's hook event spool file AND coalescer pending + // state before destroying the pane itself. Spool.Cleanup stops the + // watcher from picking up stale lines on the next tick; Ingester.Cancel + // stops the 50 ms AfterFunc timers from firing a final stale event + // after the pane is gone. if d.hookSpool != nil { d.hookSpool.Cleanup(payload.PaneID) } + if d.hookIngester != nil { + d.hookIngester.Cancel(payload.PaneID) + } d.session.DestroyPane(payload.PaneID) @@ -1859,6 +1872,24 @@ func (d *Daemon) hookEventsWatcher() { // read silently drops here — the lookup returns nil and we return without // emit. Same trust boundary as the rest of the IPC surface. func (d *Daemon) emitHookEvent(p hookevents.Payload) { + // Daemon-side enforcement of the hooks tier — covers the case where + // the script-side gate did not fire (older script on disk, env var + // stripped by an opencode wrapper, etc.). "off" drops the event; + // "default"/"verbose"/anything else fall through. Storm diagnostics + // always pass — they're the rate limiter's own internal signal. + if p.HookEvent != hookevents.EventStorm { + var mode string + switch p.Source { + case hookevents.SourceClaude: + mode = d.cfg.Notification.Hooks.Claude + case hookevents.SourceOpenCode: + mode = d.cfg.Notification.Hooks.OpenCode + } + if mode == "off" { + return + } + } + pane := d.session.Pane(p.PaneID) if pane == nil { logger.Debug("hook event for unknown pane=%s src=%s hook_event=%s", @@ -1866,10 +1897,17 @@ func (d *Daemon) emitHookEvent(p hookevents.Payload) { return } - pane.PluginMu.Lock() - pane.HookHealthy = true - pane.LastHookEventAt = time.Now() - pane.PluginMu.Unlock() + // Only real hook events count toward "this pane is hook-healthy". The + // rate limiter's own synthetic storm diagnostic would otherwise flip + // HookHealthy=true precisely when the pane has stopped delivering real + // events — silencing the legacy idle excerpt during the 30 s window + // when it's the user's last remaining notification surface. + if p.HookEvent != hookevents.EventStorm { + pane.PluginMu.Lock() + pane.HookHealthy = true + pane.LastHookEventAt = time.Now() + pane.PluginMu.Unlock() + } // Compose the PaneEvent. The Type field encodes the source so MCP // consumers can filter by "hook.claude.*" or "hook.opencode.*" without @@ -1879,25 +1917,27 @@ func (d *Daemon) emitHookEvent(p hookevents.Payload) { severity = hookevents.SeverityInfo } eventType := "hook." + p.Source + "." + p.HookEvent - ts := time.UnixMilli(p.TsMs) + // Clamp timestamp to a sane window. A hook with a clock skew (container + // with wrong NTP, malicious payload) might carry TsMs years off; the + // sidebar would pin the event at the top or bottom forever. Accept ±1h + // of the daemon's clock; anything else falls back to now. + now := time.Now() + var ts time.Time if p.TsMs == 0 { - ts = time.Now() - } - - // Copy Data so the Payload's map is not aliased downstream — the - // Ingester may still hold a reference, and emitEvent's aggregation may - // mutate Data["count"]. - var data map[string]string - if len(p.Data) > 0 { - data = make(map[string]string, len(p.Data)+2) - for k, v := range p.Data { - data[k] = v + ts = now + } else { + ts = time.UnixMilli(p.TsMs) + if ts.Before(now.Add(-time.Hour)) || ts.After(now.Add(time.Minute)) { + ts = now } } - // Enrich with source-tracking metadata so MCP consumers do not need to - // re-parse the Type prefix. - if data == nil { - data = make(map[string]string, 2) + + // Build the Data map in a single allocation. Always carries the + // source-tracking metadata so MCP consumers don't have to parse the + // Type prefix. + data := make(map[string]string, len(p.Data)+2) + for k, v := range p.Data { + data[k] = v } data["hook_source"] = p.Source data["hook_event"] = p.HookEvent @@ -2618,6 +2658,13 @@ func (d *Daemon) handleDestroyPaneReq(conn *ipc.Conn, msg *ipc.Message) { respondTo(conn, msg.ID, ipc.MsgDestroyPaneResp, ipc.DestroyPaneRespPayload{}) return } + // Mirror handleDestroyPane's PaneID validation so this MCP-facing path + // is also defended against path-traversal payloads. + if !isValidHexID(req.PaneID, "pane-") { + log.Printf("handleDestroyPaneReq: rejected malformed PaneID %q", req.PaneID) + respondTo(conn, msg.ID, ipc.MsgDestroyPaneResp, ipc.DestroyPaneRespPayload{}) + return + } pane := d.session.Pane(req.PaneID) if pane == nil { @@ -2626,11 +2673,14 @@ func (d *Daemon) handleDestroyPaneReq(conn *ipc.Conn, msg *ipc.Message) { } d.highlightPane(pane.ID) - // Same hook-events cleanup as handleDestroyPane: kill the spool file - // before the pane disappears so the watcher does not race the destroy. + // Same hook-events cleanup as handleDestroyPane: kill the spool file + + // cancel the coalescer's pending timers before the pane disappears. if d.hookSpool != nil { d.hookSpool.Cleanup(req.PaneID) } + if d.hookIngester != nil { + d.hookIngester.Cancel(req.PaneID) + } tabID := pane.TabID if err := d.session.DestroyPane(req.PaneID); err != nil { diff --git a/internal/hookevents/cancel_test.go b/internal/hookevents/cancel_test.go new file mode 100644 index 0000000..fa82fe9 --- /dev/null +++ b/internal/hookevents/cancel_test.go @@ -0,0 +1,136 @@ +package hookevents + +import ( + "sync" + "testing" + "time" +) + +// TestIngester_Cancel_DropsPendingForPane covers the C1 finding: when a +// pane is destroyed, its in-flight coalesce buffer must not fire after +// destroy. Without Cancel the AfterFunc timer would deliver one final +// stale event ~50 ms later through the emit callback. +func TestIngester_Cancel_DropsPendingForPane(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + // Submit one event for the doomed pane; the coalesce timer arms a + // 50 ms wait before flush. + ing.Submit(basePayload(1)) + + // Cancel immediately — the pending timer must be stopped and the + // pending entry removed BEFORE the natural 50 ms window closes. + ing.Cancel("pane-1") + + // Wait past the original coalesce window with a margin. If Cancel + // didn't stop the timer, the flush would fire and the recorder would + // see the event. + time.Sleep(150 * time.Millisecond) + + if got := rec.drain(); len(got) != 0 { + t.Errorf("Cancel must stop pending coalesce; emit recorder saw %d payloads", len(got)) + } +} + +func TestIngester_Cancel_DoesNotAffectOtherPanes(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + a := basePayload(1) + a.PaneID = "pane-a" + b := basePayload(2) + b.PaneID = "pane-b" + + ing.Submit(a) + ing.Submit(b) + ing.Cancel("pane-a") + + time.Sleep(150 * time.Millisecond) + + got := rec.drain() + if len(got) != 1 || got[0].PaneID != "pane-b" { + t.Errorf("Cancel must not affect other panes; got %d emits %+v, want 1 for pane-b", len(got), got) + } +} + +func TestIngester_Cancel_Idempotent(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + // Cancel for a pane with no pending state must not panic. + ing.Cancel("nonexistent-pane") + + // Submit + Cancel + Cancel (second Cancel is the test). + ing.Submit(basePayload(1)) + ing.Cancel("pane-1") + ing.Cancel("pane-1") + + time.Sleep(150 * time.Millisecond) + + if got := rec.drain(); len(got) != 0 { + t.Errorf("idempotent Cancel still suppresses pending; recorder saw %d payloads", len(got)) + } +} + +// TestIngester_Submit_AfterFlushAll_IsNoOp covers the H1 finding: after +// FlushAll closes the Ingester, a stray late Submit from any goroutine +// must NOT repopulate the pending buffer. Otherwise the AfterFunc timer +// from the new pending entry would fire after the daemon's broadcast +// pipeline has torn down. +func TestIngester_Submit_AfterFlushAll_IsNoOp(t *testing.T) { + t.Parallel() + rec := &emitRecorder{} + ing := NewIngester(rec.emit) + + ing.FlushAll() + ing.Submit(basePayload(1)) + + time.Sleep(150 * time.Millisecond) + + if got := rec.drain(); len(got) != 0 { + t.Errorf("Submit after FlushAll must be a no-op; recorder saw %d payloads", len(got)) + } +} + +// TestIngester_FlushAll_StopsPendingTimers verifies the FlushAll +// invariant that no AfterFunc timer fires after FlushAll returns. Without +// this guarantee a Submit just before FlushAll could enqueue work that +// arrives at emit after the daemon has shut down. +func TestIngester_FlushAll_StopsPendingTimers(t *testing.T) { + t.Parallel() + var emitMu sync.Mutex + var emitCountAfterFlush int + flushDone := make(chan struct{}) + + ing := NewIngester(func(p Payload) { + emitMu.Lock() + select { + case <-flushDone: + emitCountAfterFlush++ + default: + } + emitMu.Unlock() + }) + + // Submit something that would normally fire 50 ms later via AfterFunc. + ing.Submit(basePayload(1)) + + // FlushAll: drains the pending buffer synchronously AND stops the + // timer. After FlushAll returns, the AfterFunc must not run. + ing.FlushAll() + close(flushDone) + + // Wait past the natural coalesce window. If the timer was NOT + // stopped, we'd see one emit fired here. + time.Sleep(150 * time.Millisecond) + + emitMu.Lock() + got := emitCountAfterFlush + emitMu.Unlock() + if got != 0 { + t.Errorf("FlushAll must stop AfterFunc timers; got %d emits after flush returned", got) + } +} diff --git a/internal/hookevents/ingest.go b/internal/hookevents/ingest.go index f607067..3949216 100644 --- a/internal/hookevents/ingest.go +++ b/internal/hookevents/ingest.go @@ -2,10 +2,20 @@ package hookevents import ( "sort" + "strings" "sync" "time" ) +// EventStorm is the canonical hook_event name for the synthetic diagnostic +// the rate limiter emits when a pane crosses the 100-events/2s budget. The +// daemon's emitHookEvent consumer references this constant so the +// HookHealthy bookkeeping can skip these self-emitted events — otherwise a +// rate-limited pane would falsely appear "hook-healthy" during the 10 s +// penalty window and the legacy idle excerpt (the user's last fallback) +// would also be silenced. +const EventStorm = "internal.event_storm" + // Ingester is the daemon-side gate between raw Spool / IPC payloads and the // downstream emit callback that translates them to daemon.PaneEvent. It // owns two flow-control mechanisms: @@ -36,6 +46,7 @@ type Ingester struct { now func() time.Time mu sync.Mutex + closed bool // FlushAll set; future Submits no-op rates map[string]*paneRate // paneID → sliding window pending map[string]*pendingEvent // (paneID + "\x00" + hookEvent) → buffered coalesce } @@ -109,7 +120,18 @@ func NewIngester(emit func(Payload)) *Ingester { // Validation failures are silently dropped: Submit assumes the caller has // already validated. The Spool reader does this before calling Submit; // the IPC handler should do the same. +// +// After FlushAll has been called as part of daemon shutdown, Submit +// becomes a no-op so a late-arriving payload from a still-draining +// goroutine cannot leak past the documented "after FlushAll returns no +// emits will happen" guarantee. func (i *Ingester) Submit(p Payload) { + i.mu.Lock() + if i.closed { + i.mu.Unlock() + return + } + i.mu.Unlock() now := i.now() if !i.allowAndRecord(p, now) { return @@ -117,6 +139,33 @@ func (i *Ingester) Submit(p Payload) { i.coalesce(p, now) } +// Cancel discards any coalescer state for a pane being destroyed. Stops the +// AfterFunc timer for every pending event keyed by paneID and removes the +// per-pane rate-limiter bookkeeping. Critical for lifecycle correctness: +// without this, a pane destroyed while a 50 ms coalesce window is open +// would emit one final stale event ~50 ms later via the AfterFunc +// goroutine — at best a debug-log drop in emitHookEvent (pane gone), at +// worst a race window where the pane briefly exists again under a +// different identity (defensive — Quil's UUID pane IDs make actual reuse +// effectively impossible). +// +// Safe to call for a paneID that has no pending events; idempotent. +func (i *Ingester) Cancel(paneID string) { + prefix := paneID + "\x00" + i.mu.Lock() + defer i.mu.Unlock() + for k, p := range i.pending { + if !strings.HasPrefix(k, prefix) { + continue + } + if p.timer != nil { + p.timer.Stop() + } + delete(i.pending, k) + } + delete(i.rates, paneID) +} + // allowAndRecord returns true if the payload is within budget. If it // exceeds budget it returns false; if it crosses the threshold it emits a // synthesised "storm" diagnostic so the user is told about the drop. @@ -183,7 +232,7 @@ func stormPayload(paneID, source string, now time.Time) Payload { Seq: 0, PaneID: paneID, Source: source, - HookEvent: "internal.event_storm", + HookEvent: EventStorm, Title: "Hook event storm — silenced 10 s", Severity: SeverityWarning, Data: map[string]string{ @@ -261,14 +310,26 @@ func formatUint(v uint64) string { } // FlushAll is a test helper / shutdown helper that drains the coalescer's -// pending buffers immediately, emitting whatever is currently queued. -// Production code does not need this — the AfterFunc timers fire on their -// own — but Daemon.Stop calls it during shutdown so any in-flight bursts -// are surfaced before the IPC server tears down. +// pending buffers immediately, emitting whatever is currently queued. The +// hookEventsWatcher goroutine calls it during shutdown so in-flight bursts +// surface before the IPC server tears down. +// +// FlushAll also marks the Ingester closed so any concurrent or +// late-arriving Submit becomes a no-op — without this, a Submit racing +// with shutdown could repopulate i.pending after the drain, then fire 50 +// ms later when the daemon's emit pipeline has already torn down. +// +// Every pending timer is Stop()ped before drain so the AfterFunc +// goroutines do not fire a second redundant flush; the explicit flush +// loop covers what the timers would have delivered. func (i *Ingester) FlushAll() { i.mu.Lock() + i.closed = true keys := make([]string, 0, len(i.pending)) - for k := range i.pending { + for k, p := range i.pending { + if p.timer != nil { + p.timer.Stop() + } keys = append(keys, k) } // Sort so the emit order is deterministic for tests. diff --git a/internal/hookevents/safepane_test.go b/internal/hookevents/safepane_test.go new file mode 100644 index 0000000..3f49e8f --- /dev/null +++ b/internal/hookevents/safepane_test.go @@ -0,0 +1,75 @@ +package hookevents + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSafePaneID covers the path-traversal guard in Spool.Cleanup. The +// daemon's IPC handlers reject malformed PaneIDs upstream, but this +// defense-in-depth layer must independently refuse anything that could +// escape the spool directory. +func TestSafePaneID(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want bool + }{ + {"empty", "", false}, + {"realistic pane id", "pane-a1b2c3d4", true}, + {"forward slash", "pane/../etc", false}, + {"backslash", "pane\\nope", false}, + {"parent dir alone", "..", false}, + {"current dir", ".", false}, + {"parent traversal embedded", "pane-..bogus", false}, + {"null byte", "pane-\x00.id", false}, + {"single dot", "pane-1.0", true}, // dot in name is fine + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := safePaneID(tt.input); got != tt.want { + t.Errorf("safePaneID(%q): got %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestSpool_Cleanup_RejectsTraversalPaneID directly drives the Cleanup +// guard. A `../`-laden paneID must not result in an os.Remove call against +// a path outside the spool directory. +func TestSpool_Cleanup_RejectsTraversalPaneID(t *testing.T) { + t.Parallel() + root := t.TempDir() + spoolDir := filepath.Join(root, "events") + siblingDir := filepath.Join(root, "sibling") + if err := os.MkdirAll(spoolDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(siblingDir, 0o700); err != nil { + t.Fatal(err) + } + + // Seed a victim file outside the spool dir that a path-traversal + // attack would target. + victim := filepath.Join(siblingDir, "secrets.jsonl") + if err := os.WriteFile(victim, []byte("very important"), 0o600); err != nil { + t.Fatal(err) + } + + s := NewSpool(spoolDir) + if err := s.Init(); err != nil { + t.Fatal(err) + } + + // Naive concatenation would form: /../sibling/secrets.jsonl + // → after filepath.Join clean: /sibling/secrets.jsonl. The + // guard must refuse and the victim file must survive. + s.Cleanup("../sibling/secrets") + + if _, err := os.Stat(victim); err != nil { + t.Errorf("guard must prevent traversal-driven unlink; victim file vanished: %v", err) + } +} diff --git a/internal/hookevents/spool.go b/internal/hookevents/spool.go index 7bee2d9..215d3df 100644 --- a/internal/hookevents/spool.go +++ b/internal/hookevents/spool.go @@ -1,10 +1,10 @@ package hookevents import ( - "bytes" + "bufio" "encoding/json" "errors" - "io" + "fmt" "os" "path/filepath" "strings" @@ -13,6 +13,20 @@ import ( "github.com/artyomsv/quil/internal/logger" ) +// rotationThreshold is the per-pane spool size at which we truncate after a +// fully-drained read. The watcher only ever advances; without rotation a +// long-running pane's spool file grows linearly with hook-event count and +// can hit hundreds of MB over a multi-hour Claude session. 16 MiB is much +// larger than any realistic per-pane backlog and stays well clear of +// filesystem inode size guards. +const rotationThreshold = 16 * 1024 * 1024 + +// parseWarnSampleRate controls how often a per-pane producer error gets +// logged at WARN. A misbehaving producer (malformed lines in a loop) would +// otherwise spam quild.log at 200 ms ticks; sampling at 1 in N keeps the +// diagnostic visible without drowning the rest of the log. +const parseWarnSampleRate = 50 + // Spool is a per-pane JSONL file reader. The daemon polls Tick on a 200 ms // ticker; each call reads any new bytes appended since the previous read // from every .jsonl file under the spool directory, parses one @@ -30,16 +44,18 @@ import ( type Spool struct { dir string - mu sync.Mutex - offsets map[string]int64 // paneID → byte offset already consumed + mu sync.Mutex + offsets map[string]int64 // paneID → byte offset already consumed + parseErrCounts map[string]uint64 // paneID → malformed-line counter for log sampling } // NewSpool returns a Spool reading from dir. Use Init to truncate stale // files on daemon startup; Tick on each poll; Cleanup on pane destroy. func NewSpool(dir string) *Spool { return &Spool{ - dir: dir, - offsets: make(map[string]int64), + dir: dir, + offsets: make(map[string]int64), + parseErrCounts: make(map[string]uint64), } } @@ -53,11 +69,11 @@ func NewSpool(dir string) *Spool { // represent live state — is worse for a notification surface. func (s *Spool) Init() error { if err := os.MkdirAll(s.dir, 0o700); err != nil { - return err + return fmt.Errorf("hookevents: create spool dir %q: %w", s.dir, err) } entries, err := os.ReadDir(s.dir) if err != nil { - return err + return fmt.Errorf("hookevents: read spool dir %q: %w", s.dir, err) } for _, e := range entries { name := e.Name() @@ -71,6 +87,7 @@ func (s *Spool) Init() error { } s.mu.Lock() s.offsets = make(map[string]int64) + s.parseErrCounts = make(map[string]uint64) s.mu.Unlock() return nil } @@ -107,6 +124,14 @@ func (s *Spool) Tick() []Payload { } func (s *Spool) readPaneFile(paneID, path string) []Payload { + // Reject unsafe paneIDs at the read path too — symmetric with the + // Cleanup guard. A filename like "../evil.jsonl" in the spool dir + // would otherwise drive arbitrary file reads via os.Open. + if !safePaneID(paneID) { + logger.Warn("hookevents: rejected read for unsafe filename-derived paneID %q", paneID) + return nil + } + s.mu.Lock() off := s.offsets[paneID] s.mu.Unlock() @@ -127,76 +152,194 @@ func (s *Spool) readPaneFile(paneID, path string) []Payload { } size := info.Size() if size == off { - return nil // nothing new + // Nothing new. Take this opportunity to rotate the file if it has + // grown beyond the threshold and we have nothing in flight. Doing + // it on an idle tick keeps the truncate off the hot read path. + if size >= rotationThreshold { + s.rotate(paneID, path) + } + return nil } if size < off { - // File was truncated externally (e.g. test harness or a future - // rotation). Restart from the beginning. + // File was truncated externally (test harness, prior rotation, + // disk-full recovery). Restart from the beginning. off = 0 } - if _, err := f.Seek(off, io.SeekStart); err != nil { + if _, err := f.Seek(off, 0); err != nil { logger.Warn("hookevents: seek spool %q: %v", path, err) return nil } - buf, err := io.ReadAll(f) - if err != nil { - logger.Warn("hookevents: read spool %q: %v", path, err) - return nil - } - // Find the last complete line (ending in \n). Everything past that is a - // partial trailing write that we must not consume; it will be picked up - // on the next Tick. - lastNL := bytes.LastIndexByte(buf, '\n') - if lastNL < 0 { - return nil // no complete line yet + // bufio.Reader.ReadBytes('\n') lets us distinguish complete lines + // (returned with the trailing \n) from a partial trailing line + // (returned WITHOUT \n at io.EOF). The partial trailing line MUST NOT + // advance the offset — it'll be picked up on the next tick once the + // producer's pending write finishes. + // + // Per-line size cap: ReadBytes will happily allocate an unbounded + // buffer if the producer writes a multi-MB single line. We guard by + // checking the returned slice's length and dropping anything over + // MaxTotalBytes+1 with a warn. The advance still applies so we don't + // spin on it. + br := bufio.NewReader(f) + var out []Payload + consumed := off + for { + line, err := br.ReadBytes('\n') + if len(line) > 0 && line[len(line)-1] == '\n' { + // Complete line — advance offset past it regardless of + // whether validation accepts it. + consumed += int64(len(line)) + trimmed := line[:len(line)-1] + if len(trimmed) == 0 || isWhitespaceLine(trimmed) { + if err != nil { + break + } + continue + } + if p, ok := s.parseAndValidate(paneID, trimmed); ok { + out = append(out, p) + } + } + if err != nil { + // io.EOF with len(line) > 0 means we hit a partial trailing + // line — leave the offset short of it so the next tick + // picks it up. io.EOF with len(line) == 0 means we read the + // last complete line above; offset already advanced. + break + } } - consumed := off + int64(lastNL) + 1 + s.mu.Lock() s.offsets[paneID] = consumed s.mu.Unlock() + return out +} - complete := buf[:lastNL+1] - return parsePayloads(complete) +// parseAndValidate decodes one JSONL line, validates it, and enforces the +// filename↔Payload paneID match that closes the cross-pane spoof. Returns +// (payload, true) when the line passes all checks; otherwise drops the +// line with a rate-limited warn. +func (s *Spool) parseAndValidate(filenamePaneID string, line []byte) (Payload, bool) { + if len(line) > MaxTotalBytes { + s.sampledParseWarn(filenamePaneID, fmt.Sprintf("payload exceeds %d-byte cap (%d bytes)", MaxTotalBytes, len(line))) + return Payload{}, false + } + var p Payload + if err := json.Unmarshal(line, &p); err != nil { + // Log only the byte size — never the err.Error() which may include + // fragments of the raw line. Producer content can carry user + // prompt previews or secrets. + s.sampledParseWarn(filenamePaneID, fmt.Sprintf("unmarshal failed (line len %d)", len(line))) + return Payload{}, false + } + if err := p.Validate(); err != nil { + s.sampledParseWarn(filenamePaneID, fmt.Sprintf("invalid payload (hook_event=%q src=%q): %v", p.HookEvent, p.Source, err)) + return Payload{}, false + } + // Cross-pane spoofing defense: refuse to accept a payload that + // claims to belong to a different pane than the file it was written + // to. Without this a plugin running in pane A could forge events + // attributed to pane B (e.g. "Permission required" cards aimed at a + // pane the user is not currently looking at). The hook scripts set + // pane_id from $QUIL_PANE_ID which the daemon controls — a mismatch + // indicates either a bug or an attempt at attribution forgery. + if p.PaneID != filenamePaneID { + s.sampledParseWarn(filenamePaneID, fmt.Sprintf("paneID mismatch: filename=%q payload=%q", filenamePaneID, p.PaneID)) + return Payload{}, false + } + return p, true } -// parsePayloads decodes a buffer of newline-delimited JSON lines, dropping -// malformed lines with a warn log and returning the valid ones. -func parsePayloads(buf []byte) []Payload { - var out []Payload - for _, line := range bytes.Split(buf, []byte("\n")) { - if len(bytes.TrimSpace(line)) == 0 { - continue - } - if len(line) > MaxTotalBytes { - logger.Warn("hookevents: payload exceeds %d-byte cap (%d bytes), dropping", MaxTotalBytes, len(line)) - continue - } - var p Payload - if err := json.Unmarshal(line, &p); err != nil { - logger.Warn("hookevents: parse payload: %v", err) - continue - } - if err := p.Validate(); err != nil { - logger.Warn("hookevents: invalid payload from pane=%s src=%s hook_event=%s: %v", - p.PaneID, p.Source, p.HookEvent, err) - continue +// sampledParseWarn logs a parse failure at WARN, but only 1 of every +// parseWarnSampleRate occurrences per pane. A misbehaving producer (e.g. +// truncated lines in a loop) would otherwise floodlight quild.log. +func (s *Spool) sampledParseWarn(paneID, msg string) { + s.mu.Lock() + s.parseErrCounts[paneID]++ + n := s.parseErrCounts[paneID] + s.mu.Unlock() + if n%parseWarnSampleRate == 1 { + logger.Warn("hookevents: pane=%s parse drop (sampled 1/%d): %s", paneID, parseWarnSampleRate, msg) + } +} + +// rotate truncates a fully-drained spool file and resets its offset. Caller +// is responsible for ensuring the file was just observed to have no +// unconsumed bytes (size == offset). Failures land in the hook log. +func (s *Spool) rotate(paneID, path string) { + if err := os.Truncate(path, 0); err != nil { + logger.Warn("hookevents: rotate spool %q: %v", path, err) + return + } + s.mu.Lock() + s.offsets[paneID] = 0 + s.mu.Unlock() +} + +// isWhitespaceLine reports whether a slice is all spaces / tabs / nothing. +func isWhitespaceLine(b []byte) bool { + for _, c := range b { + if c != ' ' && c != '\t' { + return false } - out = append(out, p) } - return out + return true } // Cleanup removes the spool file for a destroyed pane and forgets its // offset. Idempotent; safe to call for panes that never had a spool file. +// +// Defensive against path traversal: the caller is expected to validate +// paneID upstream (the daemon's IPC handlers use isValidHexID) but we +// reject characters that could escape the spool dir as a second line of +// defense. A paneID of "../etc/passwd" would otherwise let an attacker +// who reached the IPC surface unlink arbitrary *.jsonl files under the +// daemon user. func (s *Spool) Cleanup(paneID string) { + if !safePaneID(paneID) { + logger.Warn("hookevents: rejected cleanup for unsafe paneID %q", paneID) + return + } + s.mu.Lock() delete(s.offsets, paneID) s.mu.Unlock() path := filepath.Join(s.dir, paneID+".jsonl") - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - logger.Warn("hookevents: cleanup spool %q: %v", path, err) + // Belt-and-suspenders: ensure the cleaned path lives strictly under + // s.dir even after filepath.Join's lexical processing. A future change + // to safePaneID that lets `..` slip through would still be caught here. + cleanedPath := filepath.Clean(path) + cleanedDir := filepath.Clean(s.dir) + if !strings.HasPrefix(cleanedPath, cleanedDir+string(filepath.Separator)) { + logger.Warn("hookevents: rejected cleanup escaping spool dir: %q", cleanedPath) + return + } + if err := os.Remove(cleanedPath); err != nil && !errors.Is(err, os.ErrNotExist) { + logger.Warn("hookevents: cleanup spool %q: %v", cleanedPath, err) + } +} + +// safePaneID rejects pane ids that could escape the spool directory via +// path-separator or parent-traversal segments. Matches the trust shape the +// daemon uses for its own pane id allocation (uuid-derived hex), but does +// NOT enforce the exact format — that lives in the daemon's isValidHexID +// check at the IPC ingress. Here we just refuse anything that could turn +// filepath.Join into a writable arbitrary path. +func safePaneID(id string) bool { + if id == "" { + return false + } + if strings.ContainsAny(id, `/\`+"\x00") { + return false + } + if id == "." || id == ".." { + return false + } + if strings.Contains(id, "..") { + return false } + return true } diff --git a/internal/opencodehook/scripts/quil-session-tracker.js b/internal/opencodehook/scripts/quil-session-tracker.js index 65ef20d..31f99b0 100644 --- a/internal/opencodehook/scripts/quil-session-tracker.js +++ b/internal/opencodehook/scripts/quil-session-tracker.js @@ -45,6 +45,11 @@ const FILE_EDITED_BATCH_MS = 1000; export default async function quilSessionTracker(_input) { const paneId = process.env.QUIL_PANE_ID || ""; const quilHome = process.env.QUIL_HOME || ""; + // Tier gate: "default" forwards the standard event set, "verbose" would + // add tier-2 (tool.execute.before/after for read-only tools — currently + // not wired), "off" disables all spool emission while preserving the + // session-id rotation tracking (used by Quil's resume path). + const hookMode = process.env.QUIL_HOOK_MODE || "default"; if (!paneId || !quilHome) return {}; if (!PANE_ID_RE.test(paneId)) return {}; // Defense-in-depth: the daemon controls QUIL_HOME but a leaked/forwarded @@ -85,10 +90,19 @@ export default async function quilSessionTracker(_input) { const consumeToken = () => { const now = Date.now(); - const elapsed = (now - lastRefillMs) / 1000; + // Clamp elapsed to ≥ 0. A backward clock jump (NTP correction, manual + // time change, VM snapshot resume) would otherwise produce negative + // elapsed and push `tokens` below the floor — every subsequent event + // would drop until the wall clock caught up to the pre-jump time, + // potentially days later. + const elapsed = Math.max(0, (now - lastRefillMs) / 1000); if (elapsed > 0) { tokens = Math.min(RATE_BURST, tokens + elapsed * RATE_BUDGET_PER_SEC); lastRefillMs = now; + } else if (now < lastRefillMs) { + // Reset the refill anchor on clock jumps so the next call computes + // a sane positive elapsed from the new clock baseline. + lastRefillMs = now; } if (tokens < 1) { droppedSinceLog++; @@ -112,11 +126,26 @@ export default async function quilSessionTracker(_input) { // Truncate a string with an ellipsis. Bytes counted as UTF-8 — opencode // is permitted to put unicode in payloads (file paths, prompt text). + // + // Buffer.slice on a UTF-8 byte boundary that lands mid-codepoint produces + // a string with a U+FFFD replacement char when decoded. JSON.stringify + // happily serialises U+FFFD (it round-trips as �), so the daemon's + // strict UTF-8 validator accepts the resulting line — but the user sees + // a notification card with "…" in the middle of what should be a clean + // path or prompt. Walk backwards from the byte cap to the previous + // codepoint boundary so the truncated string is always valid UTF-8. const truncate = (s, n) => { if (s == null) return ""; const buf = Buffer.from(String(s), "utf8"); if (buf.length <= n) return String(s); - return buf.slice(0, n - 3).toString("utf8") + "…"; + // Target the largest cut at n-3 (room for "…" which is 3 bytes UTF-8). + let cut = n - 3; + // Back up over any UTF-8 continuation byte (10xxxxxx); we may need to + // walk back up to 3 bytes for a 4-byte codepoint. + while (cut > 0 && (buf[cut] & 0xc0) === 0x80) { + cut--; + } + return buf.slice(0, cut).toString("utf8") + "…"; }; const truncateData = (data) => { @@ -132,6 +161,9 @@ export default async function quilSessionTracker(_input) { // Emit one Payload to the spool file. Best-effort; failures land in the // hook log but never block the opencode runtime. const spool = async (hookEvent, title, sev, data) => { + // Off-mode short-circuit: keep session-id tracking alive but drop the + // notification event surface entirely. The user opted out. + if (hookMode === "off") return; if (!consumeToken()) return; const payload = { v: 1, From 64fe09825084d955524f31e39cb8a04adeaf5113 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 17:58:46 +0200 Subject: [PATCH 13/13] chore(daemon): add hookevents watcher diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three log lines so the watcher's state is visible without modifying production behavior: - INFO at watcher start with the spool dir, so a misconfigured QUIL_HOME / EventsDir mismatch is obvious from line one of the log. - DEBUG per non-empty Tick with the payload count, so a watcher that runs but never finds events is distinguishable from one that runs and finds them but the ingester drops them. - DEBUG per successful emit with pane/src/hook_event/title, so a full-pipeline trace is one grep away. Investigating a reported case where Claude hook events landed in the spool ($QUIL_HOME/events/.jsonl) but Pane.HookHealthy never flipped to true. The new daemon will surface where the pipeline stalls — directory mismatch, parse drop, or downstream emit issue. No behavior change for non-hook code paths. --- internal/daemon/daemon.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5278c86..d45f8b6 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1840,6 +1840,7 @@ func (d *Daemon) emitEvent(e PaneEvent) { // a 200 ms p99 latency from hook fire to sidebar render keeps the user's // perception of "instant" intact. func (d *Daemon) hookEventsWatcher() { + logger.Info("hook events watcher started (200 ms tick, spool=%s)", config.EventsDir()) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -1854,7 +1855,11 @@ func (d *Daemon) hookEventsWatcher() { if d.hookSpool == nil || d.hookIngester == nil { continue } - for _, p := range d.hookSpool.Tick() { + payloads := d.hookSpool.Tick() + if len(payloads) > 0 { + logger.Debug("hook events tick: read %d payloads from spool", len(payloads)) + } + for _, p := range payloads { d.hookIngester.Submit(p) } } @@ -1896,6 +1901,8 @@ func (d *Daemon) emitHookEvent(p hookevents.Payload) { p.PaneID, p.Source, p.HookEvent) return } + logger.Debug("emit hook event pane=%s src=%s hook_event=%s title=%q", + p.PaneID, p.Source, p.HookEvent, p.Title) // Only real hook events count toward "this pane is hook-healthy". The // rate limiter's own synthetic storm diagnostic would otherwise flip