feat: outbound webhooks on session state transitions#36
Open
mirchaemanuel wants to merge 24 commits into
Open
Conversation
Specs the first lazyagent feature with internal pub-sub: a typed EventBus in internal/core/ that emits session activity state transitions, plus a new internal/webhook/ dispatcher that delivers them as HTTP POSTs with optional HMAC-SHA256 signing, event/agent filters, and async best-effort delivery.
Seventeen-task TDD plan covering the new internal/core EventBus, the ActivityTracker transition emission change, the WebhookConfig type and validation, the internal/webhook package (payload, filter, HMAC signing, dispatcher with retry/dedup/shutdown), wiring across TUI, API, GUI and main, plus user-facing docs.
Introduces SessionEvent struct and EventBus with Subscribe/Unsubscribe/Publish. Publish is non-blocking — full subscriber channels silently drop events.
ActivityTracker now accepts an optional EventBus via SetEventBus; when attached, Update publishes a SessionEvent whenever a session's resolved activity changes (including the initial Unknown→X transition on first observation). Nil bus is safe — existing callers are unaffected. Also anchors the WaitingGrace timer to s.LastActivity instead of the current poll time, so sessions already past the grace window on first observation are promoted to ActivityWaiting immediately.
The waitingSince anchor was changed to s.LastActivity in commit 4204186 as an unintended side-effect of the webhook feature branch. This reverts it to always anchor on `now` (the original behavior), keeping the grace period logic correct for TUI/GUI debouncing. The transition test is updated to avoid the waiting grace period entirely: it now exercises the Thinking→Running path via StatusExecutingTool+Bash, which is deterministic and does not depend on clock offsets.
Implements Dispatcher that subscribes to core.EventBus, fans out SessionEvents to matching WebhookConfigs, and delivers them via a worker pool with proper headers (Content-Type, User-Agent, X-Lazyagent-Event, X-Lazyagent-Delivery, X-Lazyagent-Signature).
Implement retry loop in Dispatcher.deliver using the existing backoffs slice (default 1s/5s/30s). 5xx responses and network errors trigger retries up to len(backoffs) times; 4xx responses are treated as permanent and abort immediately. Extracted doOnce helper for testability.
Multiple in-process SessionManagers (TUI + API + GUI tray) can publish the same transition. Dispatcher now coalesces duplicates within a 2s window using a per-session lastSeen map guarded by a mutex.
Change NewModel signature to accept a *core.EventBus (nil-safe) and wire it to the SessionManager via SetEventBus when non-nil. Update main.go call site to pass nil; real bus will be wired in T16.
Add webhooks.md with full field reference, payload schema, request headers, HMAC verification example, delivery semantics, and troubleshooting. Update configuration.md with a webhooks field section, roadmap.md with the v0.10 entry (removing the ⬜ placeholder), and README.md with a feature bullet in the News section.
Replace version.String() (which includes the product name) with version.Version in the User-Agent header to avoid the malformed "lazyagent/lazyagent v..." value. Add dedupTTL (5m) to Dispatcher and opportunistic eviction in shouldDedup so the lastSeen map doesn't grow unbounded; add TestDispatcher_LastSeenEvictsOldEntries to verify eviction fires when the map exceeds 64 entries.
- Skip transition emission on first observation (was flooding consumers
with synthetic events for every session present at startup)
- Disable parent webhook dispatcher when --gui is set (the tray child
already runs one; both running causes cross-process duplicates)
- Remove non-existent /full detail URL from payload — only
/api/sessions/{id} exists in the API
- Reject webhook URLs without a host (was accepted, then failed at
delivery with noisy retry logs)
- Tray dispatcher now respects ServiceShutdown ctx instead of running
detached
…ation - Normalize wildcard bind addresses (0.0.0.0, ::, empty host) to 127.0.0.1 when populating api.session_url, so the URL is actually followable by the consumer instead of being a raw bind address. - Document that the api.* payload field is only populated when the dispatcher and API server run in the same process (i.e. not in --gui modes where the tray owns webhooks and the parent owns the API).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds outbound HTTP webhooks that fire on session activity state transitions. Users configure one or more endpoints in
~/.config/lazyagent/config.jsonwith filters on event type and agent. Async best-effort delivery with optional HMAC-SHA256 signing (GitHub-style header).internal/core.EventBus— first typed pub-sub in the project — published fromActivityTracker.Updatewhenever a session's activity changesinternal/webhook/package:Dispatcherwith fan-out + worker pool (4 workers), retry on 5xx/network with backoff[1s, 5s, 30s](no retry on 4xx), 2 s dedup window for duplicate transitions emitted by multiple in-process managers (TUI + API + GUI), optionalX-Lazyagent-Signature: sha256=<hex>webhooksis empty / absentdocs/reference/webhooks.mdwith payload schema, headers, and Python HMAC verification snippetSpec:
docs/superpowers/specs/2026-05-19-outbound-webhooks-design.mdPlan:
docs/superpowers/plans/2026-05-19-outbound-webhooks.mdPayload
{ "id": "f47ac10b-...", "event": "state_transition", "session_id": "abc", "agent": "claude", "from": "idle", "to": "waiting", "project_path": "/Users/foo/code/bar", "timestamp": "2026-05-19T14:30:00Z", "api": { "session_url": "http://127.0.0.1:7421/api/sessions/abc", "detail_url": "http://127.0.0.1:7421/api/sessions/abc/full" } }api.*is included only when the API server is running (uses the resolvedsrv.Addr()).Config
{ "webhooks": [ { "name": "slack-needs-input", "url": "https://hooks.slack.com/services/T00/B00/XXX", "secret": "shared-with-receiver", "events": ["waiting"], "agents": ["claude", "codex"] } ] }eventsandagentsempty (or absent) mean "match everything".secretis optional — when set, requests carry an HMAC-SHA256 signature.Design choices
(session_id, from, to)window coalesces them. Stale entries are evicted opportunistically (TTL 5 min, guarded by a 64-entry threshold) so the dedup map can't grow unboundedwebhooks.md/api/eventsSSE pulse is untouched. A future PR could migrate it onto the new typed busTest plan
go test ./... -racepasses (16 new tests acrossinternal/coreandinternal/webhook)go build ./...andgo build -tags notray ./...succeedNon-goals (call-outs for reviewer)
config.jsondirectly, consistent with the rest of the projectv0.10); feel free to renumber at merge