@sjawhar/opencode-legion-envoy 0.11.0 → 0.12.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -158,6 +158,15 @@ dispatch({
158
158
  (`#N is not a dispatch thread`, `#N is closed; open a new thread`).
159
159
  - Same decision → same thread. A genuinely new decision → a new thread, even if it came
160
160
  up in the reply.
161
+ - **A follow-up supersedes the thread's earlier unanswered questions.** The dashboard
162
+ offers an answer only for the latest turn; earlier open questions read "superseded by a
163
+ later follow-up". If an earlier question still stands, restate it in the follow-up's
164
+ `ask` list. A follow-up with no `ask` list is one free-text question — the text under
165
+ its `## Question` — and the human answers it in prose.
166
+ - **Ask once.** An unanswered question is not a reason to post again. New facts that do
167
+ not change the decision go in a plain `gh issue comment` on the thread, not a new
168
+ `dispatch` call; a follow-up is for a changed question. A thread that fills with
169
+ restated asks is noise for the human and hides the decision it is waiting on.
161
170
  - **Read a challenge as an answer.** "Why not the bot?" is not a request for more prose;
162
171
  it is the human declining your framing. Say what you now know, then ask the narrowed
163
172
  question with new options — unless the challenge itself decides ("Why not the bot? Just
@@ -5,337 +5,171 @@ description: Use when subscribing sessions to Envoy topics, sending agent-to-age
5
5
 
6
6
  # Envoy
7
7
 
8
- Envoy is Legion's event-routing subsystem. It delivers Slack, GitHub, and agent-to-agent events to OpenCode sessions.
8
+ Envoy delivers external signals and session messages. Deliveries are at-least-once and can arrive
9
+ out of order across topics: use `id` to deduplicate and `at` to judge freshness.
9
10
 
10
- ## What the tools do
11
+ ## The one subscription you need for a PR
11
12
 
12
- - `envoy_subscribe(topics)` — make the current session RECEIVE future events on those topics
13
- - `envoy_unsubscribe(topics?)` — stop receiving some or all topics
14
- - `envoy_list()` — show the union of live local and persisted registry subscriptions, with each topic marked `live`, `registry`, or `both`
15
- - `envoy_send(session_id, message)` — SEND a message directly to another session
16
-
17
- ## Topic formats
18
-
19
- ### Agent-to-agent
20
-
21
- - Direct session route:
22
- - `notifications.agent.<session_id>`
23
-
24
- Example:
25
-
26
- - `notifications.agent.ses_2e6ca3034ffejVikSZ8mDwk0mR`
27
-
28
- ### Legion role claims
29
-
30
- - Role topic:
31
- - `notifications.role.<role>`
32
-
33
- Legion agents receive through a daemon-minted role token. Claim the role for the current
34
- session with `envoy_role_set(role="<role>")`; claiming transfers its holder with
35
- last-claim-wins semantics. A claimant does not manually subscribe to its role topic.
36
-
37
- Legion role tokens must satisfy `^[a-z0-9][a-z0-9_-]*$` and are unique across repositories:
13
+ Subscribe to the whole PR family, not individual event types:
38
14
 
39
15
  ```text
40
- legion-<project>-controller
41
- legion-<project>-<enc(owner)>__<enc(repo)>-<number>-<role>
16
+ envoy_subscribe([
17
+ "notifications.github.example-org.example-repo.pr.42.>"
18
+ ])
42
19
  ```
43
20
 
44
- `<project>` matches `[a-z0-9]+`. In owner and repository components, the injective escape
45
- encoding is `_` `_u`, `.` `_d`, and `-` `_h`; `__` separates owner from repository.
46
- The daemon owns the authoritative token-to-issue map and hands the token to each process.
47
- Do not construct a token from a partial issue reference.
48
-
49
- Claims last through the issue's post-close linger. They survive parking and worker
50
- re-creation; a re-claim re-points the role to the backing session. The daemon publishes to an
51
- issue role only while the issue is active. Inactive issue events become daemon state and later
52
- surface as derived catch-up, never as raw event replay.
53
-
54
- ### Legion exception lane
55
-
56
- `no_holder` and `delivery_failed` on a Legion role are daemon liveness signals, not a prompt
57
- for a second subscriber or a manual retry. The daemon probes the process that owns the tree:
58
-
59
- 1. If it is alive, the daemon sends a control-topic directive. The extension revives or
60
- re-creates the backing worker in code, then the daemon re-delivers the message.
61
- 2. If it is dead, the daemon resurrects the root process behind a generation lock and supplies
62
- derived catch-up plus the shared workspace handoffs.
63
-
64
- This keeps raw delivery failures out of architect context. Controller exception wakes are
65
- handled by the controller's wake routing table; every other role follows the liveness path.
66
-
67
- ### GitHub
68
-
69
- GitHub topics are **resource-scoped** — every event includes the resource type and number (or, for push/workflow events, the ref or workflow filename).
70
-
71
- **Topic structure:** `notifications.github.<owner>.<repo>.<resource_type>.<number>.<event_kind>`
72
-
73
- - PR opened/closed/merged/ready:
74
- - `notifications.github.<owner>.<repo>.pr.<number>`
75
- - Issue opened/closed/labeled:
76
- - `notifications.github.<owner>.<repo>.issue.<number>`
77
- - Comment on a PR:
78
- - `notifications.github.<owner>.<repo>.pr.<number>.comment`
79
- - Comment on an issue:
80
- - `notifications.github.<owner>.<repo>.issue.<number>.comment`
81
- - PR review submitted:
82
- - `notifications.github.<owner>.<repo>.pr.<number>.review`
83
- - Raw CI check observation (per-PR, immediate):
84
- - `notifications.github.<owner>.<repo>.pr.<number>.check`
85
- - Every PR-associated `check_run` publishes one raw observation per associated PR. `Payload` is JSON with `sha`, `name`, `status`, and `conclusion`; `PayloadSummary` names the check and its state.
86
- - CI/check summary (per-PR, per-commit, debounced):
87
- - `notifications.github.<owner>.<repo>.pr.<number>.ci`
88
- - The same `check_run` updates ingest-side per-commit state in JetStream KV bucket `envoy_ci_state`. Once the check set has been quiet for `ENVOY_CI_DEBOUNCE` (default `5s`), Envoy publishes one JSON summary. A new summary is emitted only when the check set changes; a new push starts a fresh tally, and a re-run that returns to running changes the tally.
89
- - `PayloadSummary` is a compact JSON object (`Payload` unused): `{"kind":"ci_summary","repo":"<o>/<r>","number":"<n>","sha":"<sha>","failed":{"count":N,"checks":[...]},"running":{...},"passed":{...},"queued":{...},"skipped":{...}}`. Each status is `{count, checks}` with the full sorted name list; every status is present (`{"count":0,"checks":[]}` when empty).
90
- - `check_suite` is ignored (it is a per-app rollup without a per-check name). Checks not tied to a PR are dropped, so there is no repo-wide CI topic. For non-PR visibility, use `workflow.<filename>.<action>`; individual GitHub Actions `workflow_job` events are not routed.
91
- - Mention events (per-resource):
92
- - `notifications.github.<owner>.<repo>.pr.<number>.mention`
93
- - `notifications.github.<owner>.<repo>.issue.<number>.mention`
94
- - Mention events (repo-wide — catches all mentions):
95
- - `notifications.github.<owner>.<repo>.mention`
96
- - Push events (per branch/tag):
97
- - `notifications.github.<owner>.<repo>.push.branch.<branch>`
98
- - `notifications.github.<owner>.<repo>.push.tag.<tag>`
99
- Branch and tag names with dots are sanitized to underscores (`v1.0.0` → `v1_0_0`).
100
- Push events for refs other than `refs/heads/...` and `refs/tags/...` are not routed.
101
- - Workflow run events (per workflow file):
102
- - `notifications.github.<owner>.<repo>.workflow.<filename>.<action>`
103
- Filename is the basename of `workflow_run.path` with dots sanitized (`ci.yml` → `ci_yml`).
104
- `action` is one of `requested`, `in_progress`, `completed`.
105
-
106
- **Using wildcards to subscribe broadly:**
107
- - All events in a repo: `notifications.github.<owner>.<repo>.>`
108
- - All PR events in a repo: `notifications.github.<owner>.<repo>.pr.>`
109
- - All events for a specific PR: `notifications.github.<owner>.<repo>.pr.<number>.>`
110
- - All issue events in a repo: `notifications.github.<owner>.<repo>.issue.>`
111
- - All events for a specific issue: `notifications.github.<owner>.<repo>.issue.<number>.>`
112
- - All push events in a repo: `notifications.github.<owner>.<repo>.push.>`
113
- - Pushes to main only: `notifications.github.<owner>.<repo>.push.branch.main`
114
- - All branch pushes: `notifications.github.<owner>.<repo>.push.branch.>`
115
- - All tag pushes: `notifications.github.<owner>.<repo>.push.tag.>`
116
- - All workflow events: `notifications.github.<owner>.<repo>.workflow.>`
117
- - All events for a specific workflow: `notifications.github.<owner>.<repo>.workflow.ci_yml.>`
118
- - Every workflow completion: `notifications.github.<owner>.<repo>.workflow.*.completed`
119
-
120
- Examples:
121
-
122
- - `notifications.github.example-org.example-repo.pr.9880` (PR #9880 state changes)
123
- - `notifications.github.example-org.example-repo.pr.9880.comment` (comments on PR #9880)
124
- - `notifications.github.example-org.example-repo.issue.9909.>` (all events on issue #9909)
125
- - `notifications.github.sjawhar.legion.pr.>` (all PR events across all PRs)
126
- - `notifications.github.sjawhar.legion.mention` (all @mentions repo-wide)
127
- - `notifications.github.sjawhar.legion.push.branch.main` (pushes to main)
128
- - `notifications.github.sjawhar.legion.workflow.ci_yml.in_progress` (CI workflow starts)
129
-
130
- ### Slack
131
-
132
- - Channel message events:
133
- - `notifications.slack.<team_id>.<channel_id>.message`
134
- - App mention events:
135
- - `notifications.slack.<team_id>.<channel_id>.mention`
136
- - Thread message events:
137
- - `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.message`
138
- - Thread mention events:
139
- - `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.mention`
140
-
141
- Thread timestamps are normalized: `1234567890.123456` → `1234567890_123456`
142
- (dots replaced with underscores to make the thread ID a single NATS segment).
143
-
144
- Examples:
145
-
146
- - `notifications.slack.T01234567.C0A0DHVU8HE.message`
147
- - `notifications.slack.T01234567.C0A0DHVU8HE.mention`
148
- - `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message`
149
- - `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.mention`
150
-
151
- ### Ghost Wispr
152
-
153
- - Session started events:
154
- - `notifications.ghostwispr.<session_id>.session.started`
155
- - Session ended events:
156
- - `notifications.ghostwispr.<session_id>.session.ended`
157
- - Summary ready events:
158
- - `notifications.ghostwispr.<session_id>.summary.ready`
159
-
160
- **Parameters:**
161
- - `<session_id>`: Ghost Wispr session timestamp string (e.g., `20260326041405`). Alphanumeric only, safe for NATS topic segments.
162
- - Supported kinds: `session.started`, `session.ended`, `summary.ready`
163
-
164
- Examples:
165
-
166
- - `notifications.ghostwispr.20260326041405.session.ended` (session ended)
167
- - `notifications.ghostwispr.20260326041629.summary.ready` (summary ready)
168
-
169
- ### WhatsApp
170
-
171
- - Chat message events:
172
- - `notifications.whatsapp.<phone>.<jid>.message`
173
- - Status/receipt events:
174
- - `notifications.whatsapp.<phone>.<jid>.status`
175
-
176
- **Parameters:**
177
- - `<phone>`: Connected WhatsApp account phone number in E.164 digits-only format (no `+` prefix). Example: `15551234567`. This identifies **which WhatsApp account** the events belong to — not the remote contact.
178
- - `<jid>`: Remote chat's WhatsApp JID. Individual: `PHONE@s.whatsapp.net`. Group: `ID@g.us`.
179
- - Supported kinds: `message`, `status`
180
-
181
- > **⚠️ JID dot expansion:** JID dots (`.`) become additional NATS subject tokens. For example, `5551234567@s.whatsapp.net` splits into tokens `5551234567@s`, `whatsapp`, `net`. This means individual chat topics produce 7 tokens and group chat topics produce 6 tokens. **Always use `>` (multi-level wildcard), never `*` (single-token wildcard)**, when subscribing to a chat or phone number.
182
-
183
- Examples:
184
-
185
- - `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message` (individual chat messages — note this expands to 7 NATS tokens)
186
- - `notifications.whatsapp.15551234567.120363XXX@g.us.message` (group chat messages — 6 NATS tokens)
187
-
188
- ## When to use what
189
-
190
- ### To receive future Slack/GitHub/WhatsApp events
191
-
192
- 1. Decide the exact topic(s)
193
- 2. Call `envoy_subscribe([...])`
194
- 3. Optionally call `envoy_list()` to confirm
195
-
196
- ### To talk directly to another agent/session
197
-
198
- 1. Get the target session ID
199
- 2. Call `envoy_send(session_id, message)`
200
-
201
- You do NOT need to subscribe in order to send or publish.
202
-
203
- **Session ids are not stable for the life of a conversation.** On OMP, `/fork` and `/handoff`
204
- re-mint the session id while the conversation continues (esc-esc rewinds also did, on omp
205
- 18.1.0–18.1.2 only); the extension rebinds automatically and injects an `envoy` notice naming
206
- the previous and new ids. When that notice arrives, any id you shared earlier (an
207
- `envoy_whoami` result quoted in a message, an id a peer saved) is stale — re-run
208
- `envoy_whoami` and re-announce yourself. Never treat a whoami result from earlier in the
209
- transcript as current when identifying yourself to peers.
210
-
211
- ### To wait for CI, PR checks, or other async work
212
-
213
- **Don't `sleep`-poll. Don't "check back in N minutes."** Subscribe to the event and continue with productive work — the system will wake the session when the event arrives.
21
+ NATS `>` matches **one or more** trailing tokens, so it does not match the lifecycle base
22
+ `pr.42` itself. Envoy registers that concrete base automatically when you subscribe to
23
+ `<subject>.>`, making `pr.<n>.>` the recommended default: one call receives both the lifecycle
24
+ subject and its child events.
214
25
 
215
- 1. Identify the relevant topic (e.g., `notifications.github.<owner>.<repo>.pr.<num>.>` for all PR events; `.pr.<num>.check` for immediate per-check state; `.pr.<num>.ci` for the debounced PR summary; `.workflow.<filename>.completed` for a workflow run finishing)
216
- 2. Call `envoy_subscribe([...])`
217
- 3. Move on to other work, or end the response and let the watcher wake you
218
- 4. The next response is triggered by the event, with the payload available in your context
26
+ For a typical push, this receives `pr.42` with `synchronize`, then any comments or reviews, then
27
+ one `pr.42.checks` event when that head's checks settle. The family is `pr.42` (lifecycle),
28
+ `pr.42.comment`, `pr.42.review`, `pr.42.mention`, and `pr.42.checks`. A closed lifecycle payload
29
+ carries `merged`, `merge_commit_sha`, `merged_by`, and `head_sha`.
219
30
 
220
- If you have nothing else to do, end the response. The user is not your alarm clock; do not loop with `sleep`.
31
+ The retired literal `pr.<n>.check` and `pr.<n>.ci` topics do not receive events. Existing
32
+ registrations remain dead; subscribe to `pr.<n>.checks` (or the recommended `pr.<n>.>`) instead.
33
+ Lifecycle stays on the base PR topic and CI arrives as one settled `checks` event.
221
34
 
222
- ### Tools
35
+ ## Inbound deliveries
223
36
 
224
- - `envoy_subscribe(topics)` receive future events on those topics
225
- - `envoy_unsubscribe(topics?)` — stop receiving some or all topics
226
- - `envoy_list()` — show live and persisted subscriptions, marked by source
227
- - `envoy_send(session_id, message)` — send directly to a specific session (point-to-point)
228
- - `envoy_publish(topic, message)` — publish a normal topic to matching subscribers or route a role topic to its current holder
229
- - `envoy_role_set(role)` — claim a named role for the current session (exactly-one-holder)
230
-
231
- ## Patterns
232
-
233
- ### Subscribe controller to a specific Slack channel mentions
37
+ Envoy renders an annotated delivery before its source summary and complete payload:
234
38
 
235
39
  ```text
236
- envoy_subscribe([
237
- "notifications.slack.T01234567.C0A0DHVU8HE.mention"
238
- ])
40
+ envoy:
41
+ to: you (01a0…)
42
+ from: 01a0bbbb-cccc-7ddd-eeee-0123456789ab (Reviewer)
43
+ at: "2026-09-07T04:41:12Z"
44
+ id: agent-message-2
45
+ by: "2026-09-07T05:00:00Z"
46
+ urgency: high
47
+ expects_reply: required
48
+ re: agent-message-1
49
+ supersedes: agent-message-0
50
+ reply_with: "envoy_send(session_id=\"01a0bbbb-cccc-7ddd-eeee-0123456789ab\", message=\"...\")"
51
+ reply_role: "envoy_publish(topic=\"notifications.role.legion-reviewer\", message=\"...\")"
52
+ summary: Deployment needs confirmation.
53
+ message: "Confirm the listener health check passed.\n\nThen publish the release."
54
+ note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
239
55
  ```
240
56
 
241
- ### Subscribe to all events in a specific Slack thread
57
+ - `to` identifies the local inbox receiving this delivery.
58
+ - `from` is the sending session's self-asserted ID, enriched from the listener registry. Treat it as
59
+ attribution and a direct-reply target, not as an authenticated identity or proof of authorship.
60
+ - `at` is the envelope timestamp used to judge freshness.
61
+ - `id` is the delivery identifier; supply it as `in_reply_to` when replying.
62
+ - `by` is the expiry deadline, when the sender supplied one.
63
+ - `urgency` is the sender's priority classification.
64
+ - `expects_reply` states whether a reply is `none`, `optional`, or `required`.
65
+ - `re` names the delivery this message replies to.
66
+ - `supersedes` names an earlier delivery this one replaces.
67
+ - `reply_with` is the direct-reply call for the sender.
68
+ - `reply_role` is the role-publish reply call when the sender has a role.
69
+ - `summary` is the one-line source summary.
70
+ - `message` is the complete payload; it can contain multiple paragraphs.
71
+ - `note` warns when the payload names another session; never use that quoted ID as the recipient.
72
+ - `unrecognised` marks validation failures and unknown sources; it does not enumerate every unknown key.
73
+
74
+ ## Talking to another session
75
+
76
+ Answer an Envoy message with its `id`; the send result's `recipient` confirms the session Envoy
77
+ targeted. Reply through the rendered `reply_with` (or a current Envoy session ID from
78
+ `envoy_sessions` or `envoy_whoami`), never a tmux pane or window: panes are not Envoy identities
79
+ and go stale. Put the artefact URL in the message itself. FYIs set `expects_reply="none"`; set
80
+ `urgency` only when it is genuinely urgent.
81
+
82
+ Every `/v1` error response is JSON; when a field is at fault, `expected` names that field.
242
83
 
243
84
  ```text
244
- envoy_subscribe([
245
- "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.>"
246
- ])
85
+ envoy_send(
86
+ session_id="ses_example_reviewer",
87
+ message="Review complete: artifact://review.md",
88
+ in_reply_to="agent-message-2",
89
+ expects_reply="none"
90
+ )
247
91
  ```
248
92
 
249
- ### Subscribe to only messages in a Slack thread (not mentions)
93
+ ## Waiting for CI or a merge
250
94
 
251
- ```text
252
- envoy_subscribe([
253
- "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message"
254
- ])
255
- ```
95
+ Subscribe to `notifications.github.example-org.example-repo.pr.42.>` and end the turn. The single
96
+ `pr.42.checks` event wakes you when the current head settles; a `pr.42` `closed` event with
97
+ `merged: true` tells you the PR merged. Do not create `gh` pollers.
98
+ Settlement waits for the head to be quiet for a few seconds, every reported check run to finish,
99
+ and every recorded GitHub check suite to be `completed`. It covers those reported checks and suites
100
+ for the head, not GitHub's required-checks set; until then, a silent subscription is normal.
256
101
 
257
- ### Subscribe to all threads in a Slack channel
102
+ Check settlement is at-least-once: a settlement can be followed by a `superseded_settlement: "true"` payload. Every settlement carries its attempt set `check_runs` — the latest GitHub check-run id per check name, sorted by name — plus the listener's `generation` (the record's state version) and `snapshot` (the record's hash). Consumers order same-head settlements by the attempt set, compared per shared name: no id lower and some id higher (or a new name — a new name counts as higher) is newer; every shared id equal and no new name is the same set; no id higher, no new name, and some id lower is older; anything else (a higher or new alongside a lower) is a mixed view and is dropped as a conflict (names only in the stored set are ignored — a check can vanish from GitHub's view, and a record recreated after the seven-day KV TTL starts sparse). Within one producer record per-name ids never decrease, and a consumer's fence is the per-name maximum over every view it has accepted — an accepted set merges into the fence, nothing is pruned — so the fence never decreases either: a newer attempt is newer whatever its completion time, no timestamps take part in ordering, and a name an incomplete view omitted cannot later reappear as new. At the same set the listener's `generation` orders its own settlements: lower is stale; equal is a duplicate when the `snapshot` matches and otherwise a conflict (an equal pair with a different snapshot cannot occur within one record's lifetime; a recreated record may reuse one and is dropped). A live settlement is a possibly incomplete view of the head (a missed webhook, a record recreated after the KV TTL): it decides the outcome of every name it reports — at any id the ordering accepted, including the same run observed in place — and says nothing about the rest: a known failure among them stands (the consumer keeps failure names, not a per-name status map), and the head is red while any failure remains. A consumer that reconciles a verdict from GitHub's rollup compares the rollup's attempt set the same way, but GitHub's read is complete: its failing check runs and failing commit statuses replace the stored ones wholesale. Statuses have no check run and the listener never sees them, so a consumer keeps them apart from check-run failures: a check run that shares a status's name cannot retire it — only GitHub does (likewise a deleted check's failure). A newer rollup set merges into the fence and takes the identity (no listener generation); the same set applies GitHub's verdict and keeps the listener identity for duplicate detection; an older, mixed, or empty-over-fenced set is ignored. A terminal read (green or red) then holds the tie at that set: a live settlement at the same set is accepted only if its effective outcome — the check-run failures it reports plus the stored ones it omits and the stored commit-status failures — agrees with the reconciled verdict, refreshing the listener identity without releasing GitHub's authority; a disagreeing one is stale whatever its generation until the set advances; a pending or cancelled-only read uncertifies a green head, leaves a red one untouched, and holds nothing — it releases any authority held at that set — so the terminal live settlement that follows applies at once, subject to the ordinary generation and duplicate rules (a replay or a lower generation still does not apply). Pending is therefore not a commutative join: a pending read after a live green uncertifies it until the next terminal view. Two remainders. An in-place conclusion change on an existing run id: GitHub's view stands and the listener's is recovered by the next successful, non-skipped read at that set — the dropped delivery is not replayed. A check whose highest run is deleted on GitHub: the fence keeps that id, so a rollup reporting a lower run under the same name is older until a newer run appears. A consumer that orders head changes by the PR's `updated_at` (GitHub's second resolution) accepts a read of a different head at an equal clock — a stale read returning the previous head within the same second as its replacement rewinds that consumer until its next accurate, non-skipped read. A head publishes only when at least one check has a positive run id; legacy checks without one remain in the status groups and failing names but not in `check_runs`. A legacy in-progress check whose completion is never observed holds the head unsettled until it reruns; rerun the affected check to release it.
258
103
 
259
- ```text
260
- envoy_subscribe([
261
- "notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
262
- ])
263
- ```
104
+ ## When a subscription is silent
264
105
 
265
- ### Subscribe to all PR events for example-repo
106
+ Check the `warnings` returned by `envoy_subscribe`, then inspect the active topics with
107
+ `envoy_list()`:
266
108
 
267
109
  ```text
268
110
  envoy_subscribe([
269
- "notifications.github.example-org.example-repo.pr.>"
111
+ "notifications.github.example-org.example-repo.pr.42.>"
270
112
  ])
113
+ // warnings: ["no GitHub event for example-org/example-repo in the stream's retention window; is the App installed there?"]
271
114
  ```
272
115
 
273
- ### Subscribe controller to GitHub @mentions for example-repo
116
+ A warning says no GitHub event for that repository occurred within the stream's 72-hour retention
117
+ window; it does not mean the repository was never seen. Verify the GitHub App is installed before
118
+ relying on a wakeup.
274
119
 
275
- ```text
276
- envoy_subscribe([
277
- "notifications.github.example-org.example-repo.mention"
278
- ])
279
- ```
120
+ ## Roles
280
121
 
281
- ### Message another session directly
122
+ Publish to a role; do not subscribe as its holder. A successful `envoy_publish` to a role returns
123
+ its live `holder`; an unheld role returns an error. Use `envoy_role_get(role="reviewer")` to find
124
+ the live holder first when you need one.
282
125
 
283
- ```text
284
- envoy_send(
285
- session_id="ses_2e6ca3034ffejVikSZ8mDwk0mR",
286
- message="Please continue the smoke test"
287
- )
288
- ```
126
+ ## Legion role claims
289
127
 
290
- ### Subscribe to a specific WhatsApp contact (1:1 chat)
128
+ Legion agents receive through a daemon-minted role token. Claim the assigned role with
129
+ `envoy_role_set(role="<assigned-role>")`; a claimant does not manually subscribe to its role
130
+ topic. Claims use last-claim-wins semantics, survive parking and worker re-creation, and remain
131
+ through the issue's post-close linger. The daemon owns the authoritative token-to-issue map, so do
132
+ not construct a token from a partial issue reference.
291
133
 
292
- ```text
293
- envoy_subscribe([
294
- "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>"
295
- ])
296
- ```
134
+ ## Legion exception lane
135
+
136
+ `no_holder` and `delivery_failed` for a Legion role are daemon liveness signals, not reasons to
137
+ add a second subscriber or manually retry. For example, treat a `delivery_failed` wake as the
138
+ daemon's responsibility to revive or recreate the backing worker and re-deliver; otherwise it
139
+ resurrects the root process with derived catch-up and the workspace handoffs. Raw delivery failures
140
+ stay out of architect context.
297
141
 
298
- Use `>` (not `*`) to catch all event kinds despite JID dot expansion into multiple NATS tokens.
142
+ ## Slack
299
143
 
300
- ### Subscribe to a WhatsApp group
144
+ Use the real team ID, not a workspace slug. Slack delivers a one-line prose `summary` plus a
145
+ structured `message` payload. The payload records `subtype` for edits, deletes, and bot messages;
146
+ `thread_ts` for replies; and `bot_id` and `bot_name` when a bot supplied the message.
301
147
 
302
148
  ```text
303
149
  envoy_subscribe([
304
- "notifications.whatsapp.15551234567.120363XXX@g.us.>"
150
+ "notifications.slack.T01234567.C01234567.thread.1_000.>"
305
151
  ])
306
152
  ```
307
153
 
308
- ### Subscribe to all WhatsApp events for an account
154
+ This follows every message and mention in one thread. Channel-level topics end in `.message` or
155
+ `.mention`; thread timestamps replace dots with underscores.
156
+
157
+ ## Ghost Wispr
158
+
159
+ Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
160
+ `session.started`, `session.ended`, or `summary.ready`. Their summary is concise prose and their
161
+ payload is structured; `summary_ready` includes its status, summary, and summary metadata.
309
162
 
310
163
  ```text
311
164
  envoy_subscribe([
312
- "notifications.whatsapp.15551234567.>"
165
+ "notifications.ghostwispr.session-example.summary.ready"
313
166
  ])
314
167
  ```
315
168
 
316
- Catches all conversations and event kinds for the specified phone number.
169
+ ## WhatsApp
317
170
 
318
- **When to use which:**
319
- - **1:1 chat** when monitoring a specific contact conversation (e.g., a bot handling customer queries)
320
- - **Group chat** — when monitoring a specific group for commands or events
321
- - **All chats for a phone** — when building a general WhatsApp event handler or dashboard for an account
322
-
323
- ## Important notes
324
-
325
- - Sessions choose their own Slack/GitHub subscriptions
326
- - Different sessions can subscribe to different channels/repos
327
- - Agent-to-agent delivery uses exact session IDs
328
- - `envoy_list()` distinguishes `live`, `registry`, and `both`; a `live` topic is receiving now even when the listener registry has not caught up.
329
- - For Slack, use the real `team_id` in topics (for example `T01234567`), not a workspace slug like `acme`
330
- - GitHub mention routing is body-based because GitHub has no dedicated app mention webhook event
331
-
332
- ## Synthetic Smoke Test (WhatsApp — NATS Routing Only)
333
-
334
- > **Important:** This procedure validates Envoy's NATS → listener → session delivery path using `envoy_publish`. It does **not** test real WhatsApp message ingestion. The generic MCP bridge (`packages/envoy/cmd/mcp/`) can bridge real WhatsApp events, but requires production configuration. See "Current Limitations" below.
335
- >
336
- > **Two sessions required:** `envoy_publish` sets `source_session` to the publishing session's ID. The listener skips delivering broadcasts back to the sender (`packages/envoy/cmd/listener/main.go`). You must subscribe in one session and publish from a different session.
337
-
338
- ### Step 1: Subscribe to a WhatsApp topic (Session A)
171
+ WhatsApp topics are `notifications.whatsapp.<phone>.<jid>.message` or `.status`. A JID contains
172
+ dots, which become additional NATS segments, so use `>` rather than `*` for a chat.
339
173
 
340
174
  ```text
341
175
  envoy_subscribe([
@@ -343,62 +177,17 @@ envoy_subscribe([
343
177
  ])
344
178
  ```
345
179
 
346
- ### Step 2: Verify subscription is active (Session A)
347
-
348
- ```text
349
- envoy_list()
350
- ```
351
-
352
- Confirm `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>` appears as `live` or `both`.
180
+ ### WhatsApp routing smoke test
353
181
 
354
- ### Step 3: Publish a synthetic test envelope (Session B a different session)
182
+ This checks Envoy routing, not real WhatsApp ingestion. In Session A, subscribe as above. From a
183
+ different Session B, publish a synthetic message to the same `.message` topic:
355
184
 
356
185
  ```text
357
186
  envoy_publish(
358
187
  topic="notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
359
- message="Synthetic WhatsApp smoke test: hello from envoy_publish"
188
+ message="Synthetic WhatsApp routing test"
360
189
  )
361
190
  ```
362
191
 
363
- ### Step 4: Verify delivery (Session A)
364
-
365
- Session A should receive a notification containing the text "Synthetic WhatsApp smoke test: hello from envoy_publish". This confirms:
366
- - The topic pattern matches the subscription
367
- - NATS routes the message to the listener
368
- - The listener delivers to the subscribed session (Session A ≠ the publishing session)
369
-
370
- **If the notification does not arrive:** Check `envoy_list()` in Session A. The topic should be `live` or `both`; `registry` alone does not confirm the local subscription is receiving. Verify the topic in `envoy_publish` matches the subscription pattern. Ensure you are publishing from a **different** session than the one subscribed.
371
-
372
- ### Reference: Real WhatsApp Envelope Shape
373
-
374
- When the MCP bridge (`packages/envoy/internal/mcpbridge/envelope.go`) publishes a real WhatsApp event, the Envoy envelope has this structure:
375
-
376
- ```json
377
- {
378
- "event_id": "<generated unique ID>",
379
- "source": "whatsapp",
380
- "source_event_id": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
381
- "topic": "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
382
- "dedupe_key": "whatsapp.<event_id value>",
383
- "issued_at": 1712345678000,
384
- "payload_summary": "Hello from WhatsApp",
385
- "payload_ref": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
386
- "trace_id": "<generated unique ID>"
387
- }
388
- ```
389
-
390
- **Field notes:**
391
- - `source` is `"whatsapp"` — in contrast, `envoy_publish` sets `source: "agent"` for synthetic messages
392
- - `issued_at` is in **milliseconds** (Unix epoch ms), not seconds
393
- - `dedupe_key` is `source + "." + event_id` (e.g., `"whatsapp.cuid_abc123"`)
394
- - `payload_summary` is the actual message text from the MCP resource read (truncated to 200 chars), or fallback `"whatsapp event from <uri>"` if no text content
395
- - `payload_ref` and `source_event_id` are both the MCP resource notification URI
396
- - `source_session` is **omitted** (empty) — the MCP bridge is not an OpenCode session, so no echo-skip occurs
397
- - `expires_at` is **omitted** — the bridge does not set message expiry
398
-
399
- ## Current Limitations (WhatsApp)
400
-
401
- - **No production WhatsApp event ingestion configured.** The repo contains a generic MCP→NATS bridge (`packages/envoy/cmd/mcp/` + `packages/envoy/internal/mcpbridge/`) that already supports WhatsApp topic patterns (tested in `packages/envoy/internal/integration/delivery_test.go`). However, it is not yet configured/deployed to connect to the `@sjawhar/whatsapp-mcp` server in production.
402
- - **Synthetic testing only.** The smoke test above uses `envoy_publish` to inject test messages into NATS. It validates Envoy delivery mechanics (NATS → listener → session), not true WhatsApp end-to-end delivery.
403
- - **Production wiring needed.** To receive real WhatsApp events, the MCP bridge needs to be configured with the `@sjawhar/whatsapp-mcp` server connection details (similar to how `packages/envoy/cmd/github/` and `packages/envoy/cmd/slack/` are configured for their respective platforms). The bridge would then subscribe to WhatsApp MCP resource notifications and publish Envoy envelopes to NATS automatically.
404
- - **Subscription + routing + delivery path is ready.** The contracts layer (`whatsappSubject` helper), NATS topic format, generic listener routing, and MCP bridge infrastructure all work. Only the production configuration connecting the bridge to the WhatsApp MCP server is missing.
192
+ Session A should receive it. Broadcasts do not echo to their publishing session, so one session
193
+ cannot perform both steps. Real WhatsApp delivery additionally requires a configured MCP bridge.
package/src/server.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "@legion/envoy-client/dispatch-contract";
15
15
  import { dispatchSubscriptionTopic } from "@legion/envoy-client/dispatch-subscribe";
16
16
  import { machineID } from "@legion/envoy-client/machine";
17
- import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
17
+ import { envoyToolSpecs, type ToolSpec } from "@legion/envoy-client/tool-contract";
18
18
  import { createEnvoyClient } from "@legion/envoy-client/transport";
19
19
  import { tool } from "@opencode-ai/plugin/tool";
20
20
  import { logger } from "./log";
@@ -32,16 +32,20 @@ const skillsDirectory = [
32
32
  path.resolve(moduleDirectory, "../../../skills"),
33
33
  ].find((dir) => existsSync(dir));
34
34
 
35
- const [
36
- subscribeSpec,
37
- unsubscribeSpec,
38
- listSpec,
39
- sendSpec,
40
- publishSpec,
41
- roleSetSpec,
42
- whoamiSpec,
43
- sessionsSpec,
44
- ] = envoyToolSpecs;
35
+ function toolSpec(name: string): ToolSpec {
36
+ const spec = envoyToolSpecs.find((candidate) => candidate.name === name);
37
+ if (spec === undefined) throw new Error(`envoy tool contract has no ${name}`);
38
+ return spec;
39
+ }
40
+
41
+ const subscribeSpec = toolSpec("envoy_subscribe");
42
+ const unsubscribeSpec = toolSpec("envoy_unsubscribe");
43
+ const listSpec = toolSpec("envoy_list");
44
+ const sendSpec = toolSpec("envoy_send");
45
+ const publishSpec = toolSpec("envoy_publish");
46
+ const roleSetSpec = toolSpec("envoy_role_set");
47
+ const whoamiSpec = toolSpec("envoy_whoami");
48
+ const sessionsSpec = toolSpec("envoy_sessions");
45
49
 
46
50
  // The dispatch tool's LLM-facing schema, built with OpenCode's own zod
47
51
  // (`tool.schema`): OpenCode converts plugin schemas with that instance, and a