Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ 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, 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`)
- `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()/<paneID>.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":["<abs path>"]}'`. Plugin file lives at `$QUIL_HOME/opencodehook/quil-session-tracker.js`; per-pane ids land at `$QUIL_HOME/sessions/opencode-<paneID>.id` (prefix avoids collision with claudehook's `<paneID>.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()/<paneID>.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":["<abs path>"]}'`. 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-<paneID>.id` (prefix avoids collision with claudehook's `<paneID>.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/<paneID>.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/<paneID>.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
Expand Down Expand Up @@ -83,11 +84,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
Expand Down Expand Up @@ -178,7 +179,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`)
Expand All @@ -200,6 +201,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/<pane-id>.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/<pid>/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 <n>` 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.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion cmd/quil/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<REDACT>>...<</REDACT>> markers.\n" +
Expand Down
Loading
Loading