Skip to content

feat: add channel — event subscription protocol#422

Open
4ier wants to merge 7 commits intojackwener:mainfrom
4ier:feat/channel
Open

feat: add channel — event subscription protocol#422
4ier wants to merge 7 commits intojackwener:mainfrom
4ier:feat/channel

Conversation

@4ier
Copy link
Copy Markdown

@4ier 4ier commented Mar 25, 2026

Summary

Implements opencli channel — an event subscription system that lets consumers (humans or AI agents) subscribe to platform events and receive them.

RFC: #369

Design: Consumer-Side Subscription

Instead of Channel routing events to sessions (complex), consumers subscribe to sources they care about (simple):

opencli channel subscribe github:owner/repo#42   # subscribe
opencli channel start                              # start polling
opencli channel poll github:owner/repo#42          # one-shot

Channel does exactly 3 things:

  1. Source management — poll, cursor, dedup
  2. Subscription registry — who subscribed to what
  3. Delivery — event arrives → look up subscribers → deliver

No routing logic, no session lifecycle management, no dispatcher.

What's Included

Core (src/channel/)

File Purpose
types.ts ChannelEvent, ChannelSource, ChannelSink interfaces
cursor-store.ts Persist poll cursors (~/.opencli/channel/cursors.json)
dedup.ts In-memory ring buffer dedup (10k events)
scheduler.ts Per-origin poll loops with exponential backoff
registry.ts Subscription registry (~/.opencli/channel/subscriptions.json)

GitHub Source (src/channel/sources/github.ts)

  • Uses gh api CLI (inherits auth, proxy, host config)
  • Origin formats: github:owner/repo, github:owner/repo#42, github:owner/repo/pulls, github:owner/repo/issues
  • Respects X-Poll-Interval header

Sinks

  • stdout — JSON lines, pipe-friendly
  • webhook — POST to any URL

CLI Commands

opencli channel sources [name]        — discover subscribable items
opencli channel subscribe <origin>    — subscribe
opencli channel unsubscribe <origin>  — remove subscription
opencli channel subscriptions         — list current subscriptions
opencli channel start [-d]            — start daemon (foreground or background)
opencli channel stop                  — stop daemon
opencli channel status                — show stats + cursor positions
opencli channel poll <origin>         — one-shot poll

Documentation

  • docs/channel.md — full guide with architecture, usage examples, extension guide
  • README.md updated with channel section

Constraints

  • Zero additional npm dependencies (uses node:* built-ins)
  • Source adapters call CLI tools (gh api), not raw HTTP
  • All existing tests pass (282/282)
  • tsc --noEmit clean
  • Follows existing project conventions

Verified

  • npm run build passes
  • npx tsc --noEmit clean
  • npm test — 282 tests pass
  • opencli channel --help registers correctly
  • opencli channel sources lists GitHub
  • opencli channel subscribe creates subscription
  • opencli channel poll github:jackwener/opencli#369 fetches real events

4ier added 7 commits March 25, 2026 16:05
Implements opencli channel with consumer-side subscription model:
- Sources poll platforms (GitHub via gh api), track cursors, dedup events
- Consumers subscribe to origins they care about
- Daemon delivers events to subscribers via sinks (stdout, webhook)

CLI: opencli channel {sources,subscribe,unsubscribe,subscriptions,start,stop,status,poll}
GitHub source: repo events, issue/PR comments, pulls, issues
Docs: docs/channel.md with architecture, usage, extension guide

Ref: jackwener#369
When multiple origins poll simultaneously, concurrent save() calls
could race on the temp file. Add a simple promise chain to serialize
writes, and use PID-stamped tmp filenames for extra safety.
- CRITICAL: Replace execSync with execFileSync to prevent command injection
  via malicious origin strings (e.g. $(whoami) in owner/repo)
- HIGH: Fix cursor-store mutex — use promise chain instead of broken
  check-then-act pattern; use unique tmp filenames with PID+timestamp
- HIGH: Registry save now uses unique tmp filenames
- HIGH: Webhook sink validates URL scheme (http/https only)
- HIGH: Daemon start -d checks for existing running daemon before spawning
- MEDIUM: Cursor only advances when at least one sink succeeds
- LOW: Validate --interval (reject NaN), require --webhook-url with
  --sink webhook, remove unused import
- Sink per-subscription: replace shared singleton sinks with SinkFactory
  pattern — each subscription gets a dedicated sink instance, fixing the
  webhook URL overwrite bug when multiple subscriptions use different URLs
- Issue comments cursor: use updated_at (matches GitHub's since= semantics)
  instead of created_at; detect edited comments via issue_comment.updated type;
  include updated_at in dedup ID to catch edits
- eventsDelivered: cumulative counter instead of resetting each poll
- Graceful shutdown: flush cursor store before process.exit
- Dedup: O(1) circular buffer replacing O(n) Array.shift()
- Remove unused _args parameter from ghJson
- Clean up PID file on foreground daemon shutdown (SIGINT/SIGTERM)
- Guard against spawn failure (child.pid undefined)
- Reuse validated intervalMs variable instead of re-parsing
- Consistent URL construction in pollIssues (no ?& artifact)
- Cursor only advances when ALL sinks succeed (not any-one), preventing
  a failing sink from permanently missing events
- Webhook fetch: add 30s AbortController timeout to prevent hung connections
  from blocking the poll loop indefinitely
- Registry dedup: include sinkConfig in dedup key, so two webhook subscriptions
  with different URLs are correctly stored as separate entries. Update interval
  on re-subscribe instead of silently ignoring.
- async: replace execFileSync with async execFile (promisified) in GitHub
  source — no longer blocks the event loop, multiple origins poll concurrently
- file locking: SubscriptionRegistry.withLock() uses O_CREAT|O_EXCL lockfile
  with stale detection (10s timeout). subscribe/unsubscribe use withLock to
  prevent concurrent CLI invocations from clobbering each other's changes
- all poll methods now async, awaiting gh api calls
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant