agentirc-cli 9.4.1__tar.gz → 9.5.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/CHANGELOG.md +47 -0
  2. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/PKG-INFO +1 -1
  3. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/audit.py +7 -0
  4. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/cli.py +4 -0
  5. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/config.py +10 -2
  6. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/ircd.py +58 -18
  7. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/protocol.py +132 -2
  8. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/server_link.py +59 -9
  9. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skill.py +10 -37
  10. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/api-stability.md +58 -0
  11. agentirc_cli-9.5.0a2/docs/extension-api.md +243 -0
  12. agentirc_cli-9.5.0a2/docs/superpowers/specs/2026-05-01-bot-extension-api-design.md +458 -0
  13. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/pyproject.toml +1 -1
  14. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_basic.py +9 -2
  15. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_lifecycle.py +16 -9
  16. agentirc_cli-9.5.0a2/tests/test_protocol_bot_exports.py +214 -0
  17. agentirc_cli-9.5.0a2/tests/test_wire_format_envelope.py +325 -0
  18. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/uv.lock +1 -1
  19. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/SKILL.md +0 -0
  20. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
  21. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
  22. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
  23. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
  24. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
  25. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
  26. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
  27. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.claude/skills.local.yaml.example +0 -0
  28. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.github/workflows/publish.yml +0 -0
  29. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.github/workflows/tests.yml +0 -0
  30. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/.gitignore +0 -0
  31. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/CLAUDE.md +0 -0
  32. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/LICENSE +0 -0
  33. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/README.md +0 -0
  34. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/__init__.py +0 -0
  35. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/__main__.py +0 -0
  36. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/__init__.py +0 -0
  37. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/aio.py +0 -0
  38. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/__init__.py +0 -0
  39. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/bot_manager.py +0 -0
  40. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/http_listener.py +0 -0
  41. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/__init__.py +0 -0
  42. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/constants.py +0 -0
  43. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/mesh.py +0 -0
  44. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/constants.py +0 -0
  45. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/pidfile.py +0 -0
  46. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/__init__.py +0 -0
  47. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/message.py +0 -0
  48. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/replies.py +0 -0
  49. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/__init__.py +0 -0
  50. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/context.py +0 -0
  51. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/metrics.py +0 -0
  52. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/tracing.py +0 -0
  53. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/_internal/virtual_client.py +0 -0
  54. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/channel.py +0 -0
  55. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/client.py +0 -0
  56. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/events.py +0 -0
  57. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/history_store.py +0 -0
  58. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/remote_client.py +0 -0
  59. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/room_store.py +0 -0
  60. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/rooms_util.py +0 -0
  61. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skills/__init__.py +0 -0
  62. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skills/history.py +0 -0
  63. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skills/icon.py +0 -0
  64. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skills/rooms.py +0 -0
  65. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/skills/threads.py +0 -0
  66. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/agentirc/thread_store.py +0 -0
  67. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/cli.md +0 -0
  68. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/deployment.md +0 -0
  69. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/steward/onboarding.md +0 -0
  70. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +0 -0
  71. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-task14-audit.md +0 -0
  72. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/__init__.py +0 -0
  73. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/_helpers.py +0 -0
  74. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/conftest.py +0 -0
  75. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/__init__.py +0 -0
  76. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/_fakes.py +0 -0
  77. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/_metrics_helpers.py +0 -0
  78. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_emit.py +0 -0
  79. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_lifecycle.py +0 -0
  80. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_module.py +0 -0
  81. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_parse_error.py +0 -0
  82. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_config.py +0 -0
  83. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_dispatch_span.py +0 -0
  84. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_emit_event_span.py +0 -0
  85. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_init.py +0 -0
  86. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_s2s.py +0 -0
  87. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_outbound_inject.py +0 -0
  88. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_parse_error.py +0 -0
  89. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_s2s_relay_span.py +0 -0
  90. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_init.py +0 -0
  91. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_link_inject.py +0 -0
  92. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_tracing.py +0 -0
  93. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_channel.py +0 -0
  94. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_cli.py +0 -0
  95. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_config_loader.py +0 -0
  96. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_connection.py +0 -0
  97. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_discovery.py +0 -0
  98. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_catalog.py +0 -0
  99. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_federation.py +0 -0
  100. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_history.py +0 -0
  101. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_events_reserved_nick.py +0 -0
  102. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_federation.py +0 -0
  103. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_history.py +0 -0
  104. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_link_reconnect.py +0 -0
  105. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_mentions.py +0 -0
  106. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_messaging.py +0 -0
  107. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_modes.py +0 -0
  108. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_room_persistence.py +0 -0
  109. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_rooms_federation.py +0 -0
  110. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_rooms_integration.py +0 -0
  111. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_server_icon_skill.py +0 -0
  112. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_skills.py +0 -0
  113. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0a2}/tests/test_threads.py +0 -0
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [9.5.0a2] - 2026-05-02
8
+
9
+ ### Changed
10
+
11
+ - **Wire format: SEVENT payload and IRCv3 `event-data` tag now carry the 5-field envelope** `{type, channel, nick, data, timestamp}`. Both the federation seam (`SEVENT` between linked servers) and the human-visible `#system` PRIVMSG `event-data` tag emit the new shape via `IRCd._build_event_envelope` + `IRCd._encode_event_data`. Type-specific keys (e.g. `text`, `room_id`, `purpose`) now nest under `data`; `nick` and `channel` remain at the top level. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` § Decision A for the rationale.
12
+ - `IRCd._build_event_payload` is replaced by `IRCd._build_event_envelope` (internal — no public API impact). Old name removed; the only two callsites (`_surface_event_privmsg` and `ServerLink.relay_event`'s SEVENT fallback) updated in the same commit.
13
+
14
+ ### Added
15
+
16
+ - `ServerLink._is_envelope(decoded)` — sniff helper that recognises the new envelope shape vs. the legacy data-only dict at decode time. `_handle_sevent` uses it for asymmetric tolerance (see Notes).
17
+ - `agentirc.protocol.SEVENT` — verb-name constant for the federation event-relay verb. Used by the new test suite and the outbound relay path. Other agentirc-side string-literal callsites are tracked separately in [#11](https://github.com/agentculture/agentirc/issues/11).
18
+ - `tests/test_wire_format_envelope.py` — 12 tests including a golden-file byte-equality lock-in for the canonical JSON encoding (any future change to the encoder that breaks this is a wire-format break and requires a major bump), plus regression guards for the trust-bypass and underscore-injection fixes below.
19
+
20
+ ### Security
21
+
22
+ - **`ServerLink._handle_sevent` now treats the SEVENT verb-arg channel as authoritative.** The previous draft of this PR (pre-review) used the envelope's `channel` field on the receive side, which would have allowed a malformed/malicious peer to bypass `_check_incoming_trust` by sending `target="*"` (no trust check) while putting a restricted channel name in the envelope. The verb-arg channel is what the trust gate already protects, so the receiver now ignores any envelope channel claim and uses `verb_channel` for both the trust check and the resulting `Event.channel`.
23
+ - **Peer-supplied `_`-prefixed keys are stripped from incoming SEVENT data before emit.** `_render` and similar server-internal hints would otherwise let a peer dictate human-readable surfacing on this server. The receiver strips every key starting with `_` from the decoded data before adding its own `_origin` marker.
24
+
25
+ ### Fixed
26
+
27
+ - **`IRCd._encode_event_data` fallback now produces a valid 5-field envelope instead of `b"{}"`.** A serialization failure used to emit a bare empty dict, which fails the receiver's envelope sniff and is misclassified as legacy data-only — worse than emitting an event with no type-specific payload. The fallback now emits `{type, channel, nick, data: {}, timestamp: time.time()}` so consumers can still parse the shape.
28
+
29
+ ### Notes
30
+
31
+ - **Federation interop story.** A 9.5 daemon's `_handle_sevent` sniffs the decoded payload: if it has a top-level `type` (string) and `data` (dict), treat as 9.5+ envelope; otherwise treat as ≤9.4 legacy data-only and reconstruct an `Event` from the SEVENT verb args + the bare data dict. This gives 9.5 receivers asymmetric tolerance — they read traffic from both upgraded and pre-9.5 peers — so federations can roll forward one peer at a time. The reverse direction (9.5 emit → 9.4 receive) does **not** work; 9.4 daemons have no sniff and will fail to find expected `data` fields. Operators must either upgrade all peers in lockstep, or roll one at a time and accept that 9.5-emitted events drop on still-9.4 peers until those peers also upgrade. This matches the migration cost the spec calls out.
32
+ - **Audit JSONL is unchanged.** `agentirc/_internal/telemetry/audit.py:build_audit_record` continues to record the legacy data-only payload (data dict with `nick`/`channel` merged in via `setdefault`, `_`-prefixed keys stripped). Ops dashboards and downstream log processors that read the audit JSONL keep their existing field shape; the 5-field envelope is reserved for the public network surface (SEVENT payload + IRCv3 `event-data` tag).
33
+ - **Audit timestamp on legacy peers.** When `_handle_sevent` decodes a legacy payload from a ≤9.4 peer, no originating timestamp is available; the receiver assigns `time.time()` to the reconstructed `Event.timestamp`. 9.5+ peers preserve the originating timestamp.
34
+ - This is the **wire-format slice** of the bot extension API. The next slice (9.5.0a3) wires the bot CAP behavior (`agentirc.io/bot`), the `EVENTSUB`/`EVENTUNSUB`/`EVENTPUB` handlers, and the `SubscriptionRegistry`. 9.5.0 final adds `webhook_port` unbinding and flips `docs/api-stability.md` to "current".
35
+ - Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
36
+
37
+ ## [9.5.0a1] - 2026-05-02
38
+
39
+ ### Added
40
+
41
+ - Public `agentirc.protocol` exports for the bot extension API: the `Event` dataclass, the `EventType` enum, 20 per-type `EVENT_TYPE_*` string constants, the `EVENTSUB` / `EVENTUNSUB` / `EVENT` / `EVENTERR` / `EVENTPUB` verb constants, and the `BOT_CAP = "agentirc.io/bot"` capability identifier. `Event.type` is widened to `EventType | str` so federation peers can deliver event types this version doesn't recognise. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` for the full design and `docs/extension-api.md` for the bot-author quick reference.
42
+ - `ServerConfig.event_subscription_queue_max: int = 1024` — per-subscription queue bound. Recognised by `ServerConfig.from_yaml` and `cli._resolve_config()` as a top-level YAML key. Consumed by the subscription registry that lands in 9.5.0a3.
43
+ - `agentirc.skill` keeps re-exporting `Event` and `EventType` for backward compat; the re-export shim is removed in 9.6.0 once Phase A2 confirms no consumer relies on the path.
44
+
45
+ ### Changed
46
+
47
+ - `EventType` upgraded from `enum.Enum` to `enum.StrEnum`. Members keep their `.value` strings unchanged, but Python consumers now observe new identity semantics: `isinstance(EventType.JOIN, str)` is `True`, `EventType.JOIN == "user.join"` is `True`, JSON serialization emits the bare string. Internal call sites verified — none compare `EventType.X` against a bare string today, so the truthier equality is pure improvement; downstream consumers that did string-equality round-trips against `EventType` see strictly more matches, never fewer.
48
+
49
+ ### Notes
50
+
51
+ - This is the **declarations slice** of the bot extension API. No daemon-level behavior changes: `EVENTSUB` etc. are reserved verb constants but the daemon does not yet handle them; `BOT_CAP` is exported but not yet advertised in `CAP LS` output. The wire-format envelope refactor lands in 9.5.0a2; the bot CAP behavior, subscription verbs, `EVENTPUB`, and `webhook_port` unbinding land in 9.5.0a3 / 9.5.0 final.
52
+ - Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
53
+
7
54
  ## [9.4.1] - 2026-05-01
8
55
 
9
56
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentirc-cli
3
- Version: 9.4.1
3
+ Version: 9.5.0a2
4
4
  Summary: Agent-friendly IRCd: server core for AI agent meshes
5
5
  Project-URL: Homepage, https://github.com/agentculture/agentirc
6
6
  Project-URL: Issues, https://github.com/agentculture/agentirc/issues
@@ -313,6 +313,13 @@ def build_audit_record(
313
313
 
314
314
  See culture/protocol/extensions/audit.md for the field set.
315
315
  """
316
+ # Audit JSONL records the legacy data-only payload (data dict with
317
+ # nick/channel merged in via setdefault, _-prefixed keys stripped).
318
+ # This intentionally diverges from the 9.5+ public wire envelope built
319
+ # by IRCd._build_event_envelope: ops dashboards and downstream log
320
+ # processors that read the audit JSONL keep their existing field shape;
321
+ # the 5-field envelope is reserved for the public network surface
322
+ # (SEVENT payload + IRCv3 event-data tag).
316
323
  payload = {k: v for k, v in (event.data or {}).items() if not k.startswith("_")}
317
324
  if event.nick:
318
325
  payload.setdefault("nick", event.nick)
@@ -185,6 +185,9 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
185
185
  args.data_dir, raw.get("data_dir"), os.path.expanduser("~/.culture/data")
186
186
  )
187
187
  )
188
+ event_subscription_queue_max = _pick(
189
+ None, raw.get("event_subscription_queue_max"), 1024
190
+ )
188
191
 
189
192
  cfg = ServerConfig(
190
193
  name=name or "agentirc",
@@ -195,6 +198,7 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
195
198
  links=_resolve_links(getattr(args, "link", None), raw.get("links") or []),
196
199
  system_bots=raw.get("system_bots") or {},
197
200
  telemetry=_build_telemetry(raw.get("telemetry") or {}),
201
+ event_subscription_queue_max=event_subscription_queue_max,
198
202
  )
199
203
 
200
204
  args.name = name # may be None — handler resolves via default-server file
@@ -53,13 +53,21 @@ class ServerConfig:
53
53
  links: list[LinkConfig] = field(default_factory=list)
54
54
  system_bots: dict = field(default_factory=dict)
55
55
  telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
56
+ # Bot extension API (9.5.0): per-subscription event-queue bound. When
57
+ # exceeded, the subscription is dropped with EVENTERR :backpressure-overflow
58
+ # and the bot reconciles via re-subscribe + BACKFILL. Behavior wires up in
59
+ # 9.5.0a3; the field is exposed in 9.5.0a1 so consumers can pin against the
60
+ # public surface.
61
+ event_subscription_queue_max: int = 1024
56
62
 
57
63
  @classmethod
58
64
  def from_yaml(cls, path: str | Path) -> "ServerConfig":
59
65
  """Load a ServerConfig from a YAML file.
60
66
 
61
67
  Recognises top-level ``server`` (host/port/name), ``telemetry``,
62
- ``links``, ``webhook_port``, ``data_dir``, and ``system_bots``.
68
+ ``links``, ``webhook_port``, ``data_dir``, ``system_bots``, and
69
+ ``event_subscription_queue_max`` (added in 9.5.0a1; consumed by
70
+ the subscription registry that lands in 9.5.0a3).
63
71
  Unknown top-level keys (``supervisor``, ``agents``, ``buffer_size``,
64
72
  ``poll_interval``, ``sleep_start``, ``sleep_end``) are silently
65
73
  ignored — those belong to culture's broader process supervisor,
@@ -104,7 +112,7 @@ def _yaml_kwargs(raw: dict[str, Any]) -> dict[str, Any]:
104
112
  for key in ("name", "host", "port"):
105
113
  if key in server_section:
106
114
  kwargs[key] = server_section[key]
107
- for key in ("webhook_port", "data_dir"):
115
+ for key in ("webhook_port", "data_dir", "event_subscription_queue_max"):
108
116
  if key in raw:
109
117
  kwargs[key] = raw[key]
110
118
  links_section = raw.get("links") or []
@@ -275,31 +275,63 @@ class IRCd:
275
275
  _NO_SURFACE_TYPES = NO_SURFACE_EVENT_TYPES
276
276
 
277
277
  @staticmethod
278
- def _build_event_payload(event: Event) -> dict:
279
- """Build the public event payload, enriched with canonical actor/channel."""
280
- payload = {k: v for k, v in event.data.items() if not k.startswith("_")}
281
- # Emitters that only set Event.nick (not data['nick']) still get a
282
- # correct render + payload thanks to setdefault.
283
- if event.nick:
284
- payload.setdefault("nick", event.nick)
285
- if event.channel:
286
- payload.setdefault("channel", event.channel)
287
- return payload
278
+ def _build_event_envelope(event: Event) -> dict:
279
+ """Build the public 5-field event envelope.
280
+
281
+ Shape (semver-stable as of 9.5.0a2)::
282
+
283
+ {
284
+ "type": str, # wire-form event type, e.g. "user.join"
285
+ "channel": str | None, # channel name or None
286
+ "nick": str, # actor's nickname (empty for purely-server events)
287
+ "data": dict, # type-specific payload, _-prefixed keys stripped
288
+ "timestamp": float, # Unix epoch seconds with sub-second precision
289
+ }
290
+
291
+ See ``docs/superpowers/specs/2026-05-01-bot-extension-api-design.md``
292
+ § Decision A for the rationale and federation-interop story.
293
+ """
294
+ type_wire = event.type.value if hasattr(event.type, "value") else str(event.type)
295
+ return {
296
+ "type": type_wire,
297
+ "channel": event.channel,
298
+ "nick": event.nick,
299
+ "data": {k: v for k, v in event.data.items() if not k.startswith("_")},
300
+ "timestamp": event.timestamp,
301
+ }
288
302
 
289
303
  @staticmethod
290
- def _encode_event_data(payload: dict, type_wire: str) -> str:
291
- """Base64-encode the payload as JSON; fall back to '{}' on TypeError."""
304
+ def _encode_event_data(envelope: dict, type_wire: str) -> str:
305
+ """Base64-encode the envelope as canonical JSON.
306
+
307
+ On serialization failure the fallback is a *minimal but well-formed*
308
+ envelope ``{type, channel, nick, data, timestamp}`` with empty
309
+ ``data`` and current wall-clock ``timestamp`` — never a bare ``{}``.
310
+ Subscribers and federation peers depend on the 5-field shape; an
311
+ empty-dict fallback would fail the receiver's envelope sniff and
312
+ be misclassified as legacy data-only, which is worse than emitting
313
+ an event with no type-specific payload.
314
+ """
292
315
  try:
293
316
  return base64.b64encode(
294
- json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
317
+ json.dumps(envelope, separators=(",", ":"), sort_keys=True).encode("utf-8")
295
318
  ).decode("ascii")
296
319
  except (TypeError, ValueError) as exc:
297
320
  logger.warning(
298
- "Event %s payload not JSON-serializable, surfacing with empty payload: %s",
321
+ "Event %s envelope not JSON-serializable, surfacing empty envelope: %s",
299
322
  type_wire,
300
323
  exc,
301
324
  )
302
- return base64.b64encode(b"{}").decode("ascii")
325
+ empty_envelope = {
326
+ "type": type_wire,
327
+ "channel": envelope.get("channel") if isinstance(envelope, dict) else None,
328
+ "nick": envelope.get("nick", "") if isinstance(envelope, dict) else "",
329
+ "data": {},
330
+ "timestamp": time.time(),
331
+ }
332
+ return base64.b64encode(
333
+ json.dumps(empty_envelope, separators=(",", ":"), sort_keys=True).encode("utf-8")
334
+ ).decode("ascii")
303
335
 
304
336
  async def _deliver_to_members(self, channel, msg: Message, type_wire: str) -> None:
305
337
  """Send the surfaced PRIVMSG to channel members (skipping VirtualClients)."""
@@ -352,9 +384,17 @@ class IRCd:
352
384
  origin_server = event.data.get("_origin") or self.config.name
353
385
  system_nick = f"{SYSTEM_USER_PREFIX}{origin_server}"
354
386
 
355
- payload = self._build_event_payload(event)
356
- encoded = self._encode_event_data(payload, type_wire)
357
- body = event.data.get("_render") or render_event(type_wire, payload, event.channel)
387
+ envelope = self._build_event_envelope(event)
388
+ encoded = self._encode_event_data(envelope, type_wire)
389
+ # Render templates expect the type-specific data dict (with nick/channel
390
+ # merged in for backward compat with the pre-9.5 render-function shape),
391
+ # not the envelope. Reconstruct the legacy data shape for render only.
392
+ render_data = dict(envelope["data"])
393
+ if event.nick:
394
+ render_data.setdefault("nick", event.nick)
395
+ if event.channel:
396
+ render_data.setdefault("channel", event.channel)
397
+ body = event.data.get("_render") or render_event(type_wire, render_data, event.channel)
358
398
 
359
399
  msg = Message(
360
400
  tags={EVENT_TAG_TYPE: type_wire, EVENT_TAG_DATA: encoded},
@@ -1,6 +1,6 @@
1
- """Public protocol surface for agentirc — verbs, numerics, and tags.
1
+ """Public protocol surface for agentirc — verbs, numerics, tags, and the bot extension API.
2
2
 
3
- Semver-tracked module. Three categories of constants live here:
3
+ Semver-tracked module. Five categories of public symbols live here:
4
4
 
5
5
  1. **Verb names** — IRC command verbs as bare uppercase tokens. Mostly
6
6
  RFC 2812 (PRIVMSG, JOIN, QUIT, ...), plus agentirc skill verbs
@@ -14,6 +14,16 @@ Semver-tracked module. Three categories of constants live here:
14
14
  consumers don't reach into the underscore namespace.
15
15
  3. **Message tag names** — IRCv3 tag keys for traceparent/tracestate
16
16
  and agentirc-specific event tags.
17
+ 4. **Event types and the Event dataclass** — :class:`EventType`
18
+ (a :class:`enum.StrEnum` of 20 dotted-lowercase wire strings) and
19
+ :class:`Event` (a frozen-shape dataclass). Plus 20 ``EVENT_TYPE_*``
20
+ per-type string constants for callers that prefer bare strings over
21
+ enum-coercion at JSON boundaries. Added in 9.5.0a1 as part of the
22
+ bot extension API.
23
+ 5. **Bot extension verbs and capability** — ``EVENTSUB``, ``EVENTUNSUB``,
24
+ ``EVENT``, ``EVENTERR``, ``EVENTPUB`` verb constants and
25
+ ``BOT_CAP = "agentirc.io/bot"``. Reserved in 9.5.0a1; daemon
26
+ behavior wires up in 9.5.0a3 / 9.5.0 final.
17
27
 
18
28
  Existing call sites under ``agentirc.ircd``, ``agentirc.server_link``
19
29
  and the skills modules still use inline string literals. Migrating them
@@ -31,6 +41,11 @@ clients and federation. They need a coordinated cross-repo bump.
31
41
 
32
42
  from __future__ import annotations
33
43
 
44
+ import time
45
+ from dataclasses import dataclass, field
46
+ from enum import StrEnum
47
+ from typing import Any
48
+
34
49
  # ---------------------------------------------------------------------------
35
50
  # Numeric reply codes (re-exported from the internal module)
36
51
  # ---------------------------------------------------------------------------
@@ -132,6 +147,7 @@ SROOMMETA = "SROOMMETA"
132
147
  SROOMARCHIVE = "SROOMARCHIVE"
133
148
  STAGS = "STAGS"
134
149
  STHREAD = "STHREAD"
150
+ SEVENT = "SEVENT"
135
151
  BACKFILL = "BACKFILL"
136
152
  BACKFILLEND = "BACKFILLEND"
137
153
 
@@ -164,6 +180,90 @@ ROOMETAEND = "ROOMETAEND" # SIC: typo preserved for wire compat (ROOMMETAEND ta
164
180
  ROOMETASET = "ROOMETASET" # SIC: typo preserved for wire compat (ROOMMETASET target)
165
181
 
166
182
 
183
+ # ---------------------------------------------------------------------------
184
+ # Bot extension API (9.5.0)
185
+ # ---------------------------------------------------------------------------
186
+ # Public Event dataclass + EventType enum, per-type string constants, the
187
+ # EVENTSUB / EVENTUNSUB / EVENT / EVENTERR / EVENTPUB verb names, and the
188
+ # bot-CAP token. See docs/superpowers/specs/2026-05-01-bot-extension-api-design.md
189
+ # for the wire format and verb syntax. Behavior wiring lands in 9.5.0a2/a3;
190
+ # 9.5.0a1 ships these symbols only.
191
+
192
+ # `EventType` is `StrEnum` so `EventType.JOIN == "user.join"` is True at JSON
193
+ # boundaries. Adding a new member is a minor bump; renaming or removing one
194
+ # is a major bump.
195
+
196
+
197
+ class EventType(StrEnum):
198
+ MESSAGE = "message"
199
+ JOIN = "user.join"
200
+ PART = "user.part"
201
+ QUIT = "user.quit"
202
+ TOPIC = "topic"
203
+ ROOMMETA = "room.meta"
204
+ TAGS = "tags.update"
205
+ ROOMARCHIVE = "room.archive"
206
+ THREAD_CREATE = "thread.create"
207
+ THREAD_MESSAGE = "thread.message"
208
+ THREAD_CLOSE = "thread.close"
209
+ AGENT_CONNECT = "agent.connect"
210
+ AGENT_DISCONNECT = "agent.disconnect"
211
+ CONSOLE_OPEN = "console.open"
212
+ CONSOLE_CLOSE = "console.close"
213
+ SERVER_WAKE = "server.wake"
214
+ SERVER_SLEEP = "server.sleep"
215
+ SERVER_LINK = "server.link"
216
+ SERVER_UNLINK = "server.unlink"
217
+ ROOM_CREATE = "room.create"
218
+
219
+
220
+ @dataclass
221
+ class Event:
222
+ # `type` is widened to `EventType | str` so federation peers can deliver
223
+ # event types this version doesn't recognise without raising. Subscribers
224
+ # must tolerate unknown types (forward-compat).
225
+ type: EventType | str
226
+ channel: str | None
227
+ nick: str
228
+ data: dict[str, Any] = field(default_factory=dict)
229
+ timestamp: float = field(default_factory=time.time)
230
+
231
+
232
+ # Per-type string constants — parallel to `EventType` for callers that prefer
233
+ # bare strings (e.g. comparing JSON-decoded `type` field without enum-coercing).
234
+ EVENT_TYPE_MESSAGE = "message"
235
+ EVENT_TYPE_USER_JOIN = "user.join"
236
+ EVENT_TYPE_USER_PART = "user.part"
237
+ EVENT_TYPE_USER_QUIT = "user.quit"
238
+ EVENT_TYPE_TOPIC = "topic"
239
+ EVENT_TYPE_ROOM_META = "room.meta"
240
+ EVENT_TYPE_TAGS_UPDATE = "tags.update"
241
+ EVENT_TYPE_ROOM_ARCHIVE = "room.archive"
242
+ EVENT_TYPE_THREAD_CREATE = "thread.create"
243
+ EVENT_TYPE_THREAD_MESSAGE = "thread.message"
244
+ EVENT_TYPE_THREAD_CLOSE = "thread.close"
245
+ EVENT_TYPE_AGENT_CONNECT = "agent.connect"
246
+ EVENT_TYPE_AGENT_DISCONNECT = "agent.disconnect"
247
+ EVENT_TYPE_CONSOLE_OPEN = "console.open"
248
+ EVENT_TYPE_CONSOLE_CLOSE = "console.close"
249
+ EVENT_TYPE_SERVER_WAKE = "server.wake"
250
+ EVENT_TYPE_SERVER_SLEEP = "server.sleep"
251
+ EVENT_TYPE_SERVER_LINK = "server.link"
252
+ EVENT_TYPE_SERVER_UNLINK = "server.unlink"
253
+ EVENT_TYPE_ROOM_CREATE = "room.create"
254
+
255
+ # Bot extension verbs.
256
+ EVENTSUB = "EVENTSUB"
257
+ EVENTUNSUB = "EVENTUNSUB"
258
+ EVENT = "EVENT"
259
+ EVENTERR = "EVENTERR"
260
+ EVENTPUB = "EVENTPUB"
261
+
262
+ # Bot-CAP token. Vendored namespace per IRCv3 conventions, prevents collision
263
+ # with hypothetical bare-`bot` caps from non-agentirc IRC servers.
264
+ BOT_CAP = "agentirc.io/bot"
265
+
266
+
167
267
  __all__ = [
168
268
  # Numerics
169
269
  "ERR_ALREADYREGISTRED",
@@ -245,6 +345,7 @@ __all__ = [
245
345
  "BACKFILL",
246
346
  "BACKFILLEND",
247
347
  "SERVER",
348
+ "SEVENT",
248
349
  "SJOIN",
249
350
  "SMSG",
250
351
  "SNICK",
@@ -256,4 +357,33 @@ __all__ = [
256
357
  "STAGS",
257
358
  "STHREAD",
258
359
  "STOPIC",
360
+ # Bot extension API (9.5.0)
361
+ "BOT_CAP",
362
+ "EVENT",
363
+ "EVENTERR",
364
+ "EVENTPUB",
365
+ "EVENTSUB",
366
+ "EVENTUNSUB",
367
+ "Event",
368
+ "EventType",
369
+ "EVENT_TYPE_AGENT_CONNECT",
370
+ "EVENT_TYPE_AGENT_DISCONNECT",
371
+ "EVENT_TYPE_CONSOLE_CLOSE",
372
+ "EVENT_TYPE_CONSOLE_OPEN",
373
+ "EVENT_TYPE_MESSAGE",
374
+ "EVENT_TYPE_ROOM_ARCHIVE",
375
+ "EVENT_TYPE_ROOM_CREATE",
376
+ "EVENT_TYPE_ROOM_META",
377
+ "EVENT_TYPE_SERVER_LINK",
378
+ "EVENT_TYPE_SERVER_SLEEP",
379
+ "EVENT_TYPE_SERVER_UNLINK",
380
+ "EVENT_TYPE_SERVER_WAKE",
381
+ "EVENT_TYPE_TAGS_UPDATE",
382
+ "EVENT_TYPE_THREAD_CLOSE",
383
+ "EVENT_TYPE_THREAD_CREATE",
384
+ "EVENT_TYPE_THREAD_MESSAGE",
385
+ "EVENT_TYPE_TOPIC",
386
+ "EVENT_TYPE_USER_JOIN",
387
+ "EVENT_TYPE_USER_PART",
388
+ "EVENT_TYPE_USER_QUIT",
259
389
  ]
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
  from opentelemetry import trace as otel_trace
12
12
  from opentelemetry.context import Context as _OtelContext
13
13
 
14
+ from agentirc import protocol
14
15
  from agentirc.remote_client import RemoteClient
15
16
  from agentirc.skill import Event, EventType
16
17
  from agentirc._internal.aio import maybe_await
@@ -887,34 +888,83 @@ class ServerLink:
887
888
  except ValueError:
888
889
  return type_str
889
890
 
891
+ @staticmethod
892
+ def _is_envelope(decoded: dict) -> bool:
893
+ """Sniff whether a decoded SEVENT payload is a 9.5+ 5-field envelope.
894
+
895
+ 9.5+ peers emit ``{type, channel, nick, data, timestamp}`` with a
896
+ nested ``data`` dict. 9.4 and earlier peers emit the type-specific
897
+ ``data`` dict directly (no outer envelope, no top-level ``type``).
898
+ Both are valid SEVENT payloads on the wire; this sniff lets a 9.5
899
+ receiver tolerate either shape so federations can roll forward one
900
+ peer at a time.
901
+ """
902
+ return (
903
+ isinstance(decoded.get("type"), str)
904
+ and isinstance(decoded.get("data"), dict)
905
+ )
906
+
890
907
  async def _handle_sevent(self, msg: Message) -> None:
891
908
  """Ingest a generic federated event from a peer.
892
909
 
893
- Wire format: SEVENT <origin-server> <seq> <type> <channel_or_*> :<b64-json-data>
910
+ Wire format: SEVENT <origin-server> <seq> <type> <channel_or_*> :<b64-json>
911
+
912
+ The base64-JSON payload is the 5-field envelope from 9.5+ peers, or
913
+ a bare type-specific data dict from ≤9.4 peers (asymmetric tolerance
914
+ via :meth:`_is_envelope`). Either way we reconstruct an :class:`Event`
915
+ with all five fields populated, falling back to ``time.time()`` for
916
+ ``timestamp`` only when the legacy peer didn't provide one.
894
917
  """
895
918
  if len(msg.params) < 5:
896
919
  return
897
920
  origin, _seq, type_str, target, b64 = msg.params[:5]
898
- channel = None if target == "*" else target
921
+ verb_channel = None if target == "*" else target
899
922
 
900
923
  if origin != self.peer_name:
901
924
  logger.warning("SEVENT origin %s != peer %s", origin, self.peer_name)
902
925
 
903
- if channel is not None and not self._check_incoming_trust(channel):
926
+ if verb_channel is not None and not self._check_incoming_trust(verb_channel):
904
927
  return
905
928
 
906
- data = self._decode_sevent_payload(b64, self.peer_name)
907
- if data is None:
929
+ decoded = self._decode_sevent_payload(b64, self.peer_name)
930
+ if decoded is None:
908
931
  return
909
932
 
933
+ if self._is_envelope(decoded):
934
+ # 9.5+ envelope. Verb-arg type wins on mismatch (defensive — the
935
+ # SEVENT line is the canonical type carrier).
936
+ raw_data = decoded["data"]
937
+ nick = decoded.get("nick") or f"{SYSTEM_USER_PREFIX}{origin}"
938
+ timestamp = decoded.get("timestamp")
939
+ if not isinstance(timestamp, (int, float)):
940
+ timestamp = time.time()
941
+ else:
942
+ # ≤9.4 legacy: decoded dict IS the data payload.
943
+ raw_data = decoded
944
+ nick = raw_data.get("nick", f"{SYSTEM_USER_PREFIX}{origin}")
945
+ timestamp = time.time()
946
+
947
+ # Verb-arg channel is authoritative for routing and trust: the
948
+ # _check_incoming_trust() above gated on it. Ignore any channel claim
949
+ # in the envelope — a malformed/malicious peer must not be able to
950
+ # bypass trust by sending target="*" while putting a restricted
951
+ # channel name in the envelope.
952
+ channel = verb_channel
953
+
954
+ # Strip incoming `_`-prefixed keys from the peer-supplied data before
955
+ # we add our own `_origin` marker. `_render` and similar server-side
956
+ # hints must not be peer-controllable; allowing them would let a
957
+ # peer dictate the human-readable surfacing on this server.
958
+ data = {k: v for k, v in raw_data.items() if not k.startswith("_")}
910
959
  data["_origin"] = origin
911
960
  type_enum = self._parse_event_type(type_str)
912
961
 
913
962
  ev = Event(
914
963
  type=type_enum,
915
964
  channel=channel,
916
- nick=data.get("nick", f"{SYSTEM_USER_PREFIX}{origin}"),
965
+ nick=nick,
917
966
  data=data,
967
+ timestamp=timestamp,
918
968
  )
919
969
  await self.server.emit_event(ev)
920
970
 
@@ -1014,14 +1064,14 @@ class ServerLink:
1014
1064
  else:
1015
1065
  # If no typed relay exists, fall back to generic SEVENT.
1016
1066
  # v1 assumes all peers support SEVENT; cap negotiation is deferred — see plan task 12.
1017
- payload = self.server._build_event_payload(event)
1018
- encoded = self.server._encode_event_data(payload, event_type_str)
1067
+ envelope = self.server._build_event_envelope(event)
1068
+ encoded = self.server._encode_event_data(envelope, event_type_str)
1019
1069
  target = event.channel or "*"
1020
1070
  # Egress trust check: channel-scoped events respect should_relay; global events always relay
1021
1071
  if event.channel is None or self.should_relay(event.channel):
1022
1072
  seq = self.server._seq # current local seq; peer stores but doesn't re-sequence
1023
1073
  await self.send_raw(
1024
- f":{origin} SEVENT {origin} {seq} {event_type_str} {target} :{encoded}"
1074
+ f":{origin} {protocol.SEVENT} {origin} {seq} {event_type_str} {target} :{encoded}"
1025
1075
  )
1026
1076
  relayed = True
1027
1077
 
@@ -1,9 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
4
- from dataclasses import dataclass, field
5
- from enum import Enum
6
- from typing import TYPE_CHECKING, Any
3
+ from typing import TYPE_CHECKING
4
+
5
+ # Event and EventType moved to agentirc.protocol in 9.5.0a1 as part of the
6
+ # bot extension API public surface. This module keeps re-exporting them so
7
+ # internal call sites and any pre-9.5 vendored consumers keep working; the
8
+ # re-export is removed in 9.6.0 once Phase A2 confirms no consumer relies on
9
+ # this path.
10
+ from agentirc.protocol import Event, EventType
11
+
12
+ __all__ = ["Event", "EventType", "Skill"]
7
13
 
8
14
  if TYPE_CHECKING:
9
15
  from agentirc.client import Client
@@ -11,39 +17,6 @@ if TYPE_CHECKING:
11
17
  from agentirc._internal.protocol.message import Message
12
18
 
13
19
 
14
- class EventType(Enum):
15
- MESSAGE = "message"
16
- JOIN = "user.join"
17
- PART = "user.part"
18
- QUIT = "user.quit"
19
- TOPIC = "topic"
20
- ROOMMETA = "room.meta"
21
- TAGS = "tags.update"
22
- ROOMARCHIVE = "room.archive"
23
- THREAD_CREATE = "thread.create"
24
- THREAD_MESSAGE = "thread.message"
25
- THREAD_CLOSE = "thread.close"
26
- # Lifecycle + link events introduced by mesh-events feature.
27
- AGENT_CONNECT = "agent.connect"
28
- AGENT_DISCONNECT = "agent.disconnect"
29
- CONSOLE_OPEN = "console.open"
30
- CONSOLE_CLOSE = "console.close"
31
- SERVER_WAKE = "server.wake"
32
- SERVER_SLEEP = "server.sleep"
33
- SERVER_LINK = "server.link"
34
- SERVER_UNLINK = "server.unlink"
35
- ROOM_CREATE = "room.create"
36
-
37
-
38
- @dataclass
39
- class Event:
40
- type: EventType
41
- channel: str | None
42
- nick: str
43
- data: dict[str, Any] = field(default_factory=dict)
44
- timestamp: float = field(default_factory=time.time)
45
-
46
-
47
20
  class Skill:
48
21
  name: str = ""
49
22
  commands: set[str] = set()
@@ -12,6 +12,35 @@ import only from these three modules.
12
12
  | [`agentirc.cli`](#agentirccli) | `main()`, `dispatch(argv) -> int` | Public, semver-tracked |
13
13
  | [`agentirc.protocol`](#agentircprotocol) | Verb constants, numeric reply codes, IRCv3/extension tag names | Public, semver-tracked |
14
14
 
15
+ > **Bot extension API — phased rollout:**
16
+ >
17
+ > - **9.5.0a1 (shipped):** `agentirc.protocol` exports the `Event`
18
+ > dataclass, the `EventType` enum (now `StrEnum`), 20 per-type
19
+ > `EVENT_TYPE_*` string constants, the `EVENTSUB`/`EVENTUNSUB`/
20
+ > `EVENT`/`EVENTERR`/`EVENTPUB` verb constants, and the
21
+ > `BOT_CAP = "agentirc.io/bot"` capability identifier.
22
+ > `ServerConfig` gains the `event_subscription_queue_max: int = 1024`
23
+ > field. **Declarations slice — symbols are importable but inert.**
24
+ > - **9.5.0a2 (current alpha — wire-format slice):** `IRCd._build_event_payload`
25
+ > replaced by `IRCd._build_event_envelope`. Federated `SEVENT` payload
26
+ > and the IRCv3 `event-data` tag on `#system` PRIVMSGs now carry the
27
+ > canonical 5-field envelope `{type, channel, nick, data, timestamp}`.
28
+ > `ServerLink._handle_sevent` sniffs the shape so 9.5 receivers
29
+ > tolerate both envelope (9.5+ peers) and legacy data-only (≤9.4
30
+ > peers); 9.5→9.4 emit breaks until peers upgrade. Internal change;
31
+ > no new public symbols. See CHANGELOG `[9.5.0a2]` § Notes.
32
+ > - **9.5.0a3 (planned):** bot-CAP behavior, `EVENTSUB`/`EVENTUNSUB`
33
+ > handlers, the in-memory `SubscriptionRegistry`, and `EVENTPUB`
34
+ > handler. Daemon starts advertising `BOT_CAP` in `CAP LS` output.
35
+ > - **9.5.0 (final):** `webhook_port` no longer bound; `cli.md` /
36
+ > `deployment.md` updated; this block flips from "phased rollout" to
37
+ > "current," and the version-history table picks up a 9.5.0 row.
38
+ >
39
+ > Wire format and verb syntax are specified in
40
+ > [`docs/superpowers/specs/2026-05-01-bot-extension-api-design.md`](superpowers/specs/2026-05-01-bot-extension-api-design.md);
41
+ > a quick reference for bot authors is at [`docs/extension-api.md`](extension-api.md).
42
+ > Tracking issue: [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
43
+
15
44
  ## Semver contract
16
45
 
17
46
  Following [SemVer 2.0](https://semver.org/):
@@ -179,6 +208,35 @@ Re-exported from `agentirc._internal.protocol.replies`. About 33 names:
179
208
  Re-exported from `agentirc._internal.telemetry.context`:
180
209
  `TRACEPARENT_TAG`, `TRACESTATE_TAG`, `EVENT_TAG_TYPE`, `EVENT_TAG_DATA`.
181
210
 
211
+ ### Reserved for 9.5.0: bot extension surface
212
+
213
+ These additions are **specified but not yet implemented**. They will land
214
+ together as a single minor bump in 9.5.0 — see the design spec at
215
+ [`docs/superpowers/specs/2026-05-01-bot-extension-api-design.md`](superpowers/specs/2026-05-01-bot-extension-api-design.md)
216
+ for rationale, federation behavior, and acceptance criteria, and
217
+ [`docs/extension-api.md`](extension-api.md) for the bot-author quick
218
+ reference.
219
+
220
+ - **Event verbs:** `EVENTSUB`, `EVENTUNSUB`, `EVENT`, `EVENTERR`, `EVENTPUB`. Subscribers stream events with filter syntax (`type=`/`channel=`/`nick=` AND-ed globs); `EVENTPUB` lets a bot emit its own typed events back into the stream (server-side validation of `type` against `EVENT_TYPE_RE`; `nick` and `timestamp` derived server-side, not trusted from the client).
221
+ - **Bot capability:** `BOT_CAP = "agentirc.io/bot"`. When negotiated via
222
+ the existing CAP REQ/ACK flow, the connection is treated as a bot:
223
+ silent JOIN/PART/QUIT broadcasts, no auto-op on channel creation,
224
+ `+` prefix in NAMES output, `B` flag in WHO output, authorized to
225
+ issue `EVENTSUB`.
226
+ - **Event dataclass and enum:** `Event` and `EventType` (currently
227
+ internal in `agentirc.skill`). Promoted to public for Python consumers.
228
+ Wire format — not the Python class names — is the contract; non-Python
229
+ bots pin against the JSON shape documented in `extension-api.md`.
230
+ - **Per-type string constants:** `EVENT_TYPE_MESSAGE`,
231
+ `EVENT_TYPE_USER_JOIN`, …, one per type-string in the canonical
232
+ vocabulary. Convenience for callers that prefer non-enum-aware
233
+ constants.
234
+
235
+ The `ServerConfig` additions (one new field
236
+ `event_subscription_queue_max: int = 1024`) and the `webhook_port`
237
+ binding-removal are described under
238
+ [`agentirc.config`](#agentircconfig) once 9.5.0 lands.
239
+
182
240
  ### Wire-format quirks (preserved verbatim)
183
241
 
184
242
  Four known wire-format issues are **preserved** rather than fixed,