@sjawhar/pi-envoy 10.25.0 → 10.25.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/dist/skills/envoy/SKILL.md +47 -286
- package/dist/skills/envoy/references/check-settlement.md +62 -0
- package/dist/skills/envoy/references/deliveries.md +62 -0
- package/dist/skills/envoy/references/legion.md +12 -0
- package/dist/skills/envoy/references/roles.md +10 -0
- package/dist/skills/envoy/references/sources.md +41 -0
- package/package.json +1 -1
|
@@ -1,310 +1,71 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: envoy
|
|
3
|
-
description: Use when
|
|
3
|
+
description: Use when you send a message to another agent session, reply to an Envoy delivery, find your own or a peer's session id, subscribe to a topic (a GitHub pull request, CI checks, a Dispatch issue, Slack), publish to a role or team topic, or wait for CI or a merge. Also when you are a subagent that must report to another session, or a subscription stays silent.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Envoy
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
out of order across topics: use `id` to deduplicate and `at` to judge freshness.
|
|
8
|
+
If this skill disagrees with the code, a command's output or its `--help`, follow those and say which line of this skill is wrong.
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
Envoy delivers external events and messages between sessions. Delivery is at-least-once and can come out of order across topics: drop a repeated `id`, and when two deliveries conflict, act on the one with the later `at`.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
## Send a message to another session
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
The owner and the repository are one token each, with every dot in the name written `_`:
|
|
22
|
-
`acme/site.io`'s PR family is `notifications.github.acme.site_io.pr.42.>`, and a topic spelled
|
|
23
|
-
`acme.site.io` receives nothing.
|
|
24
|
-
|
|
25
|
-
NATS `>` matches **one or more** trailing tokens, so it does not match the lifecycle base
|
|
26
|
-
`pr.42` itself. Envoy registers that concrete base automatically when you subscribe to
|
|
27
|
-
`<subject>.>`, making `pr.<n>.>` the recommended default: one call receives both the lifecycle
|
|
28
|
-
subject and its child events.
|
|
29
|
-
|
|
30
|
-
For a typical push, this receives `pr.42` with `synchronize`, then any comments or reviews, then
|
|
31
|
-
one `pr.42.checks` event when that head's checks settle. A settlement is published for every
|
|
32
|
-
commit of the pull request whose checks settle, the current head or not (a head pushed with
|
|
33
|
-
GitHub's `skip-checks` trailer runs none, so the commit before it settles for it), and its `sha`
|
|
34
|
-
names the commit: compare it with the head you are waiting on. The family is `pr.42` (lifecycle),
|
|
35
|
-
`pr.42.comment`, `pr.42.review`, `pr.42.mention`, and `pr.42.checks`. A closed lifecycle payload
|
|
36
|
-
carries `merged`, `merge_commit_sha`, `merged_by`, and `head_sha`.
|
|
37
|
-
|
|
38
|
-
The retired literal `pr.<n>.check` and `pr.<n>.ci` topics do not receive events. Existing
|
|
39
|
-
registrations remain dead; subscribe to `pr.<n>.checks` (or the recommended `pr.<n>.>`) instead.
|
|
40
|
-
Lifecycle stays on the base PR topic and CI arrives as one settled `checks` event.
|
|
41
|
-
|
|
42
|
-
## Inbound deliveries
|
|
43
|
-
|
|
44
|
-
Envoy renders an annotated delivery before its source summary and complete payload:
|
|
14
|
+
- Take the target id from the delivery's `reply_with`, or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
|
|
15
|
+
- Reply with `in_reply_to=<the delivery's id>`, so the recipient can match the answer to its question.
|
|
16
|
+
- Read the `recipient` in the send result, the session Envoy targeted: if it is not the one you meant, resend to the right id.
|
|
17
|
+
- Put the URL or path of what you report in the message, because the recipient cannot find your files any other way.
|
|
18
|
+
- Set `expects_reply="none"` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon.
|
|
45
19
|
|
|
46
20
|
```text
|
|
47
|
-
|
|
48
|
-
to: you (ses_example_recipient)
|
|
49
|
-
from: 01a0bbbb-cccc-7ddd-eeee-0123456789ab (Reviewer)
|
|
50
|
-
at: "2026-09-07T04:41:12Z"
|
|
51
|
-
id: agent-message-2
|
|
52
|
-
by: "2026-09-07T05:00:00Z"
|
|
53
|
-
urgency: high
|
|
54
|
-
expects_reply: required
|
|
55
|
-
re: agent-message-1
|
|
56
|
-
supersedes: agent-message-0
|
|
57
|
-
reply_with:
|
|
58
|
-
tool: envoy_send
|
|
59
|
-
args:
|
|
60
|
-
session_id: 01a0bbbb-cccc-7ddd-eeee-0123456789ab
|
|
61
|
-
in_reply_to: agent-message-2
|
|
62
|
-
message: ...
|
|
63
|
-
reply_role:
|
|
64
|
-
tool: envoy_publish
|
|
65
|
-
args:
|
|
66
|
-
topic: notifications.role.legion-reviewer
|
|
67
|
-
message: ...
|
|
68
|
-
summary: Deployment needs confirmation.
|
|
69
|
-
message: "Confirm the listener health check passed.\n\nThen publish the release."
|
|
70
|
-
note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
|
|
21
|
+
envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply="none")
|
|
71
22
|
```
|
|
72
23
|
|
|
73
|
-
|
|
74
|
-
- `from` is the sending session's self-asserted ID, enriched from the listener registry. Treat it as
|
|
75
|
-
attribution and a direct-reply target, not as an authenticated identity or proof of authorship.
|
|
76
|
-
- `at` is the envelope timestamp used to judge freshness.
|
|
77
|
-
- `id` is the delivery identifier; supply it as `in_reply_to` when replying.
|
|
78
|
-
- `by` is the expiry deadline, when the sender supplied one.
|
|
79
|
-
- `urgency` is the sender's priority classification.
|
|
80
|
-
- `expects_reply` states whether a reply is `none`, `optional`, or `required`.
|
|
81
|
-
- `re` names the delivery this message replies to. A Dispatch frame names it as a `dispatch://`
|
|
82
|
-
ref — the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a
|
|
83
|
-
document ask) or the message (`dispatch://KEY/message/<id>`) — never the quoted text; the
|
|
84
|
-
question head, when there is one, is `dispatch.question`.
|
|
85
|
-
- `supersedes` names an earlier delivery this one replaces.
|
|
86
|
-
- `reply_with` is the direct-reply call for the sender; on a Dispatch frame it is `command:`, the
|
|
87
|
-
`dispatch` command line that answers it, with `...` where your text goes.
|
|
88
|
-
- `reply_role` is the role-publish reply call when the sender has a role.
|
|
89
|
-
- `summary` is the one-line source summary.
|
|
90
|
-
- `message` is the complete payload; it can contain multiple paragraphs.
|
|
91
|
-
- `note` warns when the payload names another session; never use that quoted ID as the recipient.
|
|
92
|
-
- `unrecognised` marks validation failures and unknown sources; it does not enumerate every unknown key.
|
|
93
|
-
|
|
94
|
-
## Talking to another session
|
|
95
|
-
|
|
96
|
-
Answer an Envoy message with its `id`; the send result's `recipient` confirms the session Envoy
|
|
97
|
-
targeted. Reply through the rendered `reply_with` (or a current Envoy session ID from
|
|
98
|
-
`envoy_sessions` or `envoy_whoami`), never a tmux pane or window: panes are not Envoy identities
|
|
99
|
-
and go stale. Put the artefact URL in the message itself. FYIs set `expects_reply="none"`; set
|
|
100
|
-
`urgency` only when it is genuinely urgent.
|
|
101
|
-
|
|
102
|
-
**A peer's message is its sender's view at `at`, not the current state.** Before you wait on, act
|
|
103
|
-
on, or repeat a fact a message carries about a third thing (a deploy pending, a PR held, an ask
|
|
104
|
-
unanswered), re-read it at the live source the fact names, and always once it is over an hour
|
|
105
|
-
old: the deployment's status, the issue's event log, the ask's own state (`dispatch open-asks`,
|
|
106
|
-
whose description already says to call it before saying you are waiting on a human). On
|
|
107
|
-
2026-09-26 a peer's 05:20Z "needs a manual deploy before I can run it" was false by 05:22Z, when
|
|
108
|
-
the platform had deployed on its own; waiting on the message instead of the deployment's status
|
|
109
|
-
held the work it gated for eleven hours.
|
|
24
|
+
### Your own address in a subagent
|
|
110
25
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
message="Review complete: artifact://review.md",
|
|
117
|
-
in_reply_to="agent-message-2",
|
|
118
|
-
expects_reply="none"
|
|
119
|
-
)
|
|
120
|
-
```
|
|
26
|
+
- `envoy_whoami`'s `session_id` is where a reply to you arrives. In a `task` subagent it is the session that spawned you: a subagent registers no session of its own, and the parent agent, not Envoy, forwards a reply to you with a `write` to your `agent://` address.
|
|
27
|
+
- Never give a peer the `subagent` field from `envoy_whoami`: it names your host session and is not an address.
|
|
28
|
+
- Start a subagent's message with who you are and who receives the reply ("From <name>, a subagent of session <id>; a reply to <id> reaches me"), because the recipient sees only the parent as sender.
|
|
29
|
+
- When `session_id` is empty (the process took no Envoy identity, or it can no longer place the session that spawned you), your messages carry no sender at all: name yourself in the body.
|
|
30
|
+
- In a subagent, a publish to a role your parent holds reaches nobody, because the listener drops a role message whose holder is its source. `envoy_publish` says so in its answer. To reach the parent, `write` to its `agent://` address instead of publishing. `envoy_send` to any other session works.
|
|
121
31
|
|
|
122
|
-
|
|
32
|
+
## Read a delivery
|
|
123
33
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
`session_id` is empty when nothing can be reached: a process that took no Envoy identity, or a
|
|
131
|
-
subagent whose spawning session this process can no longer place (it forked away, and other
|
|
132
|
-
top-level sessions are running). Then your messages carry no sender either, so say who you are
|
|
133
|
-
in the message body. An empty address is deliberate — being handed an unrelated live session
|
|
134
|
-
would send your peers to an agent that never spawned you.
|
|
135
|
-
|
|
136
|
-
Whether a publish reaches a session turns on where that session takes its deliveries, not on
|
|
137
|
-
whether the topic is a built-in one or your own. A session that holds its own NATS connection —
|
|
138
|
-
every Oh My Pi session, and a Claude Code one — receives every message published on a subject it
|
|
139
|
-
subscribes to, whoever the source: its own publish, and a subagent's publish to a topic the
|
|
140
|
-
session that spawned you subscribes to, included. The listener skips its push to such a session
|
|
141
|
-
entirely, so the push path's skip of the source session never decides anything for it, and a
|
|
142
|
-
topic the stream retains and a team topic that travels live (below) behave alike here. The
|
|
143
|
-
session whose own publish does not come back is one the listener delivers to by push, one
|
|
144
|
-
registered with a serve port such as the OpenCode plugin; such a session receives no team topic
|
|
145
|
-
at all, since it holds no NATS subscription of its own.
|
|
146
|
-
|
|
147
|
-
A role lane is the case that needs the `agent://` hop, because a role is not a subject its holder
|
|
148
|
-
subscribes to: the listener resolves the live holder and forwards the message to it, and drops
|
|
149
|
-
the message when that holder is the session the message names as its source. Inside a `task`
|
|
150
|
-
subagent that source is the session that spawned you, so a publish to a role your parent holds is
|
|
151
|
-
accepted, answers with that holder, and reaches nobody — `envoy_publish` says so in its answer.
|
|
152
|
-
Use a `write` to the parent's `agent://` address for that one hop. `envoy_send` to any other
|
|
153
|
-
session, including a reply, is unaffected.
|
|
154
|
-
|
|
155
|
-
### Delivery capabilities
|
|
156
|
-
|
|
157
|
-
Each session row from `envoy_sessions` carries `capabilities`, the targeted-delivery modes that
|
|
158
|
-
session's host honours: `aside` (a message queued beside the model's work), `btw` (an ephemeral
|
|
159
|
-
question the host answers without disturbing the current turn), and `steer` (an interjection at
|
|
160
|
-
the next tool boundary). An OMP session advertises `aside`, `btw`, and `steer` (`aside` and
|
|
161
|
-
`steer` on a host that cannot run a side turn); a Claude Code session advertises `aside` only, because
|
|
162
|
-
its channel notifications queue for the next turn. Target a session only with a mode it
|
|
163
|
-
advertises.
|
|
164
|
-
|
|
165
|
-
## Waiting for CI or a merge
|
|
166
|
-
|
|
167
|
-
Subscribe to `notifications.github.example-org.example-repo.pr.42.>` and end the turn. The single
|
|
168
|
-
`pr.42.checks` event whose `sha` is the current head wakes you when it settles (an earlier
|
|
169
|
-
commit's settlement can arrive first); a `pr.42` `closed` event with
|
|
170
|
-
`merged: true` tells you the PR merged. Do not create `gh` pollers. A Legion implementer waits this
|
|
171
|
-
way only on a re-run of a red its task names; otherwise it pushes and completes its phase, and the
|
|
172
|
-
daemon reads CI for it (`skill://legion-worker`).
|
|
173
|
-
Settlement waits for the commit to be quiet for a few seconds, every reported check run to finish,
|
|
174
|
-
and every recorded GitHub check suite to be `completed`. It covers those reported checks and suites
|
|
175
|
-
for that commit, not GitHub's required-checks set; until then, a silent subscription is normal.
|
|
176
|
-
|
|
177
|
-
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 settlements of one commit 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.
|
|
178
|
-
|
|
179
|
-
## When a subscription is silent
|
|
180
|
-
|
|
181
|
-
Check the `warnings` returned by `envoy_subscribe`, then inspect the active topics with
|
|
182
|
-
`envoy_list()`:
|
|
183
|
-
|
|
184
|
-
```text
|
|
185
|
-
envoy_subscribe([
|
|
186
|
-
"notifications.github.example-org.example-repo.pr.42.>"
|
|
187
|
-
])
|
|
188
|
-
// warnings: ["no GitHub event for example-org/example-repo in the stream's retention window; is the App installed there?"]
|
|
189
|
-
```
|
|
34
|
+
- `from` is the sender's own claim. Use it to attribute and to reply, never as proof of who wrote the message.
|
|
35
|
+
- `note` warns that the payload names another session. Never send to that quoted id; reply to `from`.
|
|
36
|
+
- Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`.
|
|
37
|
+
- A peer's message is its sender's view at `at`, not the current state. Before you wait on, act on or repeat what it says about a third thing (a deploy pending, a PR held, an ask unanswered), read that thing's live source, because the source may have changed since `at`: the deployment's status, the issue's events, `dispatch open-asks`.
|
|
38
|
+
- Every field of a rendered delivery, and who receives a publish: `skill://envoy/references/deliveries.md`.
|
|
190
39
|
|
|
191
|
-
|
|
192
|
-
window; it does not mean the repository was never seen. Verify the GitHub App is installed before
|
|
193
|
-
relying on a wakeup. A topic that spells a repository name with its dot, followed by a kind, draws
|
|
194
|
-
a warning naming the spelling Envoy publishes (`acme.site_io` for `acme/site.io`); subscribe to
|
|
195
|
-
that one instead. A token after the repository that is not a GitHub topic kind (`checks` in
|
|
196
|
-
`acme.widgets.checks.>`) draws a warning naming the kinds.
|
|
40
|
+
## Subscribe to a topic
|
|
197
41
|
|
|
198
|
-
|
|
42
|
+
Every topic starts with `notifications.`.
|
|
199
43
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
44
|
+
| Source | Topic |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| A pull request: lifecycle, comments, reviews, mentions, checks | `github.<owner>.<repo>.pr.<n>.>` |
|
|
47
|
+
| Other GitHub | `github.<owner>.<repo>.issue.<n>[.comment\|.mention]`, `.push.branch.<name>`, `.push.tag.<name>`, `.workflow.<file>.<action>` |
|
|
48
|
+
| A Dispatch issue or document | `dispatch.issue.<KEY>.>`, `dispatch.document.<PROJECT>.<SLUG>.>` |
|
|
49
|
+
| Your own inbox | `agent.<session_id>` |
|
|
50
|
+
| Slack, Ghost Wispr, WhatsApp | `skill://envoy/references/sources.md` |
|
|
207
51
|
|
|
208
|
-
|
|
52
|
+
- Write the owner, the repository, a branch or tag name and a workflow file as one token each, with every dot written `_`: `acme/site.io` is `github.acme.site_io`, and `ci.yml` is `workflow.ci_yml`. A slash stays. A topic spelled with the dot receives nothing.
|
|
53
|
+
- Subscribe to the pull request's whole family, `pr.<n>.>`. Envoy also subscribes you to the lifecycle subject `pr.<n>`, which NATS `>` alone does not match, because `>` needs one or more trailing tokens.
|
|
54
|
+
- Read the `warnings` that `envoy_subscribe` returns, and confirm the active topics with `envoy_list`. A warning names the spelling Envoy publishes, or a token that is not a GitHub topic kind; fix the topic it names. "No GitHub event ... in the stream's retention window" means nothing came from that repository in the last 72 hours: check that the GitHub App that sends Envoy its webhooks is installed on that repository before you rely on a wake.
|
|
209
55
|
|
|
210
|
-
|
|
211
|
-
its live `holder`; an unheld role returns an error. Use `envoy_role_get(role="reviewer")` to find
|
|
212
|
-
the live holder first when you need one.
|
|
56
|
+
## Wait for CI or a merge
|
|
213
57
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`no holder` until that transcript resumes or another session claims the role.
|
|
58
|
+
- Subscribe to `github.<owner>.<repo>.pr.<n>.>` and end the turn. Never start a `gh` poller: the event wakes you, and a poller burns turns.
|
|
59
|
+
- One `pr.<n>.checks` event arrives per commit whose checks settle, the head or not. Compare its `sha` with the head you wait on, because an earlier commit's settlement can arrive first. A push with GitHub's `skip-checks` trailer runs no checks, and several can sit on top (a Legion handoff push is one): wait for the settlement of the nearest ancestor that is not such a push, the CI head `skill://legion-worker/references/merge-gate.md` reads.
|
|
60
|
+
- Settlement waits until the commit has had no check event for 5 seconds (`ENVOY_CI_DEBOUNCE`) and every reported check run and check suite of it has finished. No event arrives before that. Envoy does not read GitHub's required-checks list, so a green settlement does not mean GitHub will merge the PR.
|
|
61
|
+
- A `pr.<n>` event with `closed` and `merged: true` means the PR merged; it carries `merge_commit_sha`, `merged_by` and `head_sha`.
|
|
62
|
+
- A later settlement of the same commit can carry `superseded_settlement: "true"`. Code that orders settlements follows `skill://envoy/references/check-settlement.md`.
|
|
63
|
+
- A Legion implementer waits this way only on a re-run of a red check its task names. Otherwise it pushes and completes its phase, and the daemon reads CI for it (`skill://legion-worker`).
|
|
221
64
|
|
|
222
|
-
|
|
223
|
-
is unheld, held by a session that is no longer live, or held by the id this session continues (a
|
|
224
|
-
fork's parent). If a different live session holds it — the parent of a `/fork` whose role moved to
|
|
225
|
-
the child, or a second process on the same transcript — the listener refuses atomically and the
|
|
226
|
-
resumed session logs a warning and holds nothing. Only an explicit `envoy_role_set` is
|
|
227
|
-
last-claim-wins. Re-running `envoy_role_set` for a role this session already holds is harmless.
|
|
228
|
-
Moving to a different role is refused while another plugin holds subscriptions that last only as
|
|
229
|
-
long as this session keeps its current role (the refusal names them), and `envoy_unsubscribe`
|
|
230
|
-
keeps those subscriptions open, listing them as kept.
|
|
231
|
-
|
|
232
|
-
## Legion role claims
|
|
233
|
-
|
|
234
|
-
Legion agents receive through a daemon-minted role token. Claim the assigned role with
|
|
235
|
-
`envoy_role_set(role="<assigned-role>")`; a claimant does not manually subscribe to its role
|
|
236
|
-
topic. Claims use last-claim-wins semantics, survive parking and worker re-creation, and remain
|
|
237
|
-
through the issue's post-close linger. The daemon owns the authoritative token-to-issue map, so do
|
|
238
|
-
not construct a token from a partial issue reference.
|
|
239
|
-
|
|
240
|
-
## Legion exception lane
|
|
241
|
-
|
|
242
|
-
`no_holder`, `delivery_failed`, and `receipt_timeout` for a Legion role are daemon liveness
|
|
243
|
-
signals, not reasons to add a second subscriber or manually retry. `no_holder`: no live session
|
|
244
|
-
holds the role. `delivery_failed`: the message was not forwarded at all — the holder lookup
|
|
245
|
-
failed, the holder was stale, or the publish errored. `receipt_timeout`: the listener forwarded
|
|
246
|
-
the message to a registered, live holder and no receipt arrived inside the window; its payload
|
|
247
|
-
keeps `original_topic`, `event_id`, `payload`, and the original `dedupe_key`, so the daemon can
|
|
248
|
-
re-send the same message and the receiver's dedupe drops a copy that did arrive. Treat any of
|
|
249
|
-
these wakes as the daemon's responsibility to revive or recreate the backing worker and
|
|
250
|
-
re-deliver; otherwise it resurrects the root process with derived catch-up and the workspace
|
|
251
|
-
handoffs. Raw delivery failures stay out of architect context.
|
|
252
|
-
|
|
253
|
-
## Slack
|
|
254
|
-
|
|
255
|
-
Use the real team ID, not a workspace slug. Slack delivers a one-line prose `summary` plus a
|
|
256
|
-
structured `message` payload. The payload records `subtype` for edits, deletes, and bot messages;
|
|
257
|
-
`thread_ts` for replies; and `bot_id` and `bot_name` when a bot supplied the message.
|
|
258
|
-
|
|
259
|
-
```text
|
|
260
|
-
envoy_subscribe([
|
|
261
|
-
"notifications.slack.T01234567.C01234567.thread.1_000.>"
|
|
262
|
-
])
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
This follows every message and mention in one thread. Channel-level topics end in `.message` or
|
|
266
|
-
`.mention`; thread timestamps replace dots with underscores.
|
|
267
|
-
|
|
268
|
-
Reply in a thread with `slack_post(channel=<channel_id>, thread_ts=<the message's thread_ts, or
|
|
269
|
-
its ts>, text=…)`, which posts as the Legion Slack app with the bot token the secrets broker grants
|
|
270
|
-
the session (`LEGION_SLACK_BOT_TOKEN`). A refusal carries Slack's own error code, or the broker's
|
|
271
|
-
state when it has not granted the token. The post is sent once; when the answer is that Slack did
|
|
272
|
-
not answer, the reply may already be there, so do not post it again.
|
|
273
|
-
|
|
274
|
-
## Ghost Wispr
|
|
275
|
-
|
|
276
|
-
Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
|
|
277
|
-
`session.started`, `session.ended`, or `summary.ready`. Their summary is concise prose and their
|
|
278
|
-
payload is structured; `summary_ready` includes its status, summary, and summary metadata.
|
|
279
|
-
|
|
280
|
-
```text
|
|
281
|
-
envoy_subscribe([
|
|
282
|
-
"notifications.ghostwispr.session-example.summary.ready"
|
|
283
|
-
])
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
## WhatsApp
|
|
287
|
-
|
|
288
|
-
WhatsApp topics are `notifications.whatsapp.<phone>.<jid>.message` or `.status`. A JID contains
|
|
289
|
-
dots, which become additional NATS segments, so use `>` rather than `*` for a chat.
|
|
290
|
-
|
|
291
|
-
```text
|
|
292
|
-
envoy_subscribe([
|
|
293
|
-
"notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>"
|
|
294
|
-
])
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### WhatsApp routing smoke test
|
|
298
|
-
|
|
299
|
-
This checks Envoy routing, not real WhatsApp ingestion. In Session A, subscribe as above. From a
|
|
300
|
-
different Session B, publish a synthetic message to the same `.message` topic:
|
|
301
|
-
|
|
302
|
-
```text
|
|
303
|
-
envoy_publish(
|
|
304
|
-
topic="notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
|
|
305
|
-
message="Synthetic WhatsApp routing test"
|
|
306
|
-
)
|
|
307
|
-
```
|
|
65
|
+
## Roles and team topics
|
|
308
66
|
|
|
309
|
-
|
|
310
|
-
|
|
67
|
+
- Send to one session with `envoy_send`, to the one session that holds a named duty with a role, and to a group with a team topic.
|
|
68
|
+
- Publish to a role with `envoy_publish(topic="notifications.role.<role>", ...)`; never subscribe to a role topic, because the listener forwards a role message to its holder only. A publish returns the live `holder`; an unheld role returns an error. `envoy_role_get(role=...)` finds the holder first.
|
|
69
|
+
- For a team, the group subscribes to `notifications.team.<name>.>`, and any member publishes to `notifications.team.<name>.all`. Envoy treats any other `notifications.` topic that no source in the table uses the same way. It reaches only the sessions subscribed when it is published (yours too, when you subscribe), and is not kept for a session that subscribes later. A topic outside `notifications.` is refused.
|
|
70
|
+
- How a role claim survives a resume, a fork or a dead process: `skill://envoy/references/roles.md`.
|
|
71
|
+
- In a Legion pane, the plugin claims your role at session start; read `skill://envoy/references/legion.md` before you claim, release or publish to a role, or answer a role delivery failure there.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Ordering pull request check settlements
|
|
2
|
+
|
|
3
|
+
Read this when you write or review code that consumes `pr.<n>.checks` settlements and must decide which of several settlements of one commit is current.
|
|
4
|
+
|
|
5
|
+
## What a settlement carries
|
|
6
|
+
|
|
7
|
+
- A commit can settle more than once. A later settlement can carry `superseded_settlement: "true"`, and an exact copy can arrive, because delivery is at-least-once.
|
|
8
|
+
- `check_runs`, the attempt set: the latest GitHub check-run id per check name, sorted by name.
|
|
9
|
+
- `generation`: the listener record's state version. `snapshot`: the record's hash.
|
|
10
|
+
- A head publishes only when at least one check has a positive run id. Legacy checks without one appear in the status groups and the failing names, never in `check_runs`.
|
|
11
|
+
|
|
12
|
+
## Order two attempt sets of one commit
|
|
13
|
+
|
|
14
|
+
Compare the ids of the names both sets share:
|
|
15
|
+
|
|
16
|
+
1. No id lower, and some id higher or a new name: newer. A new name counts as higher.
|
|
17
|
+
2. Every shared id equal and no new name: the same set.
|
|
18
|
+
3. No id higher, no new name, and some id lower: older.
|
|
19
|
+
4. Anything else (a higher or new id alongside a lower one): a mixed view. Drop it as a conflict.
|
|
20
|
+
|
|
21
|
+
Ignore names that only the stored set has: a check can vanish from GitHub's view, and a record recreated after the seven-day KV TTL starts sparse.
|
|
22
|
+
|
|
23
|
+
## Keep a fence
|
|
24
|
+
|
|
25
|
+
- The fence is the per-name maximum id over every set you accepted. Merge each accepted set into it, and prune nothing.
|
|
26
|
+
- Within one listener record, per-name ids never decrease, so the fence never decreases either.
|
|
27
|
+
- So a newer attempt is newer whatever its completion time, no timestamp takes part in ordering, and a name an incomplete view left out cannot later come back as new.
|
|
28
|
+
|
|
29
|
+
## At the same set, order by generation
|
|
30
|
+
|
|
31
|
+
- A lower `generation` is stale.
|
|
32
|
+
- An equal `generation` with the same `snapshot` is a duplicate. With a different `snapshot`, drop it as a conflict: that pair occurs only when a record was recreated and reused a generation.
|
|
33
|
+
|
|
34
|
+
## Apply a live settlement
|
|
35
|
+
|
|
36
|
+
- A live settlement (one the listener sent) may be an incomplete view of the head: a webhook was missed, or the record was recreated after the KV TTL.
|
|
37
|
+
- It decides the outcome of every name it reports, at any id the ordering accepted, the same run seen in place included. It says nothing about the names it leaves out.
|
|
38
|
+
- A known failure among the names it leaves out stands, because you keep failure names, not a per-name status map. The head is red while any failure remains.
|
|
39
|
+
|
|
40
|
+
## Reconcile with GitHub's rollup
|
|
41
|
+
|
|
42
|
+
- Compare the rollup's attempt set in the same way. GitHub's read is complete: its failing check runs and failing commit statuses replace the stored ones wholesale.
|
|
43
|
+
- Keep commit statuses apart from check-run failures, because statuses have no check run and the listener never sees them. A check run with a status's name cannot retire that status; only GitHub's read can. The same holds for a deleted check's failure.
|
|
44
|
+
- A newer rollup set merges into the fence and takes the identity, with no listener generation.
|
|
45
|
+
- The same set applies GitHub's verdict and keeps the listener identity for duplicate detection.
|
|
46
|
+
- Ignore an older set, a mixed set, and an empty set when the fence already holds ids.
|
|
47
|
+
|
|
48
|
+
## After a terminal GitHub read
|
|
49
|
+
|
|
50
|
+
A terminal read is a green or red one. GitHub's verdict then holds authority at that set:
|
|
51
|
+
|
|
52
|
+
- Accept a live settlement at the same set only if its effective outcome agrees with GitHub's verdict. The effective outcome is the check-run failures it reports, plus the stored ones it leaves out, plus the stored commit-status failures. Accepting it refreshes the listener identity, and GitHub's verdict keeps its authority.
|
|
53
|
+
- A live settlement that disagrees is stale, whatever its generation, until the set advances.
|
|
54
|
+
- A pending or cancelled-only read takes the green off a green head, leaves a red head red, and ends GitHub's authority at that set. The next terminal live settlement then applies at once, under the usual generation and duplicate rules: a replay or a lower generation still does not apply.
|
|
55
|
+
- So the order of reads matters: a pending read after a live green takes the green off until the next terminal view.
|
|
56
|
+
|
|
57
|
+
## Known gaps
|
|
58
|
+
|
|
59
|
+
- A run whose conclusion changes in place, on the same run id: GitHub's view stands. The listener's view comes back with the next successful, non-skipped read at that set; the dropped delivery is not replayed.
|
|
60
|
+
- A check whose highest run is deleted on GitHub: the fence keeps that id, so a rollup that reports a lower run under that name is older until a newer run appears.
|
|
61
|
+
- A consumer that orders head changes by the PR's `updated_at` (one-second resolution) accepts a read of another head at an equal clock. A stale read that returns the previous head in the same second as its replacement rewinds that consumer until its next accurate, non-skipped read.
|
|
62
|
+
- A legacy in-progress check whose completion is never seen holds the head unsettled until it reruns: rerun that check to release it.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Envoy deliveries
|
|
2
|
+
|
|
3
|
+
Read this when a delivery field is unclear, or when a publish did not reach a session you expected.
|
|
4
|
+
|
|
5
|
+
## A rendered delivery
|
|
6
|
+
|
|
7
|
+
Envoy renders an annotated delivery before its source summary and complete payload:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
envoy:
|
|
11
|
+
to: you (ses_example_recipient)
|
|
12
|
+
from: 01a0bbbb-cccc-7ddd-eeee-0123456789ab (Reviewer)
|
|
13
|
+
at: "2026-09-07T04:41:12Z"
|
|
14
|
+
id: agent-message-2
|
|
15
|
+
by: "2026-09-07T05:00:00Z"
|
|
16
|
+
urgency: high
|
|
17
|
+
expects_reply: required
|
|
18
|
+
re: agent-message-1
|
|
19
|
+
supersedes: agent-message-0
|
|
20
|
+
reply_with:
|
|
21
|
+
tool: envoy_send
|
|
22
|
+
args:
|
|
23
|
+
session_id: 01a0bbbb-cccc-7ddd-eeee-0123456789ab
|
|
24
|
+
in_reply_to: agent-message-2
|
|
25
|
+
message: ...
|
|
26
|
+
reply_role:
|
|
27
|
+
tool: envoy_publish
|
|
28
|
+
args:
|
|
29
|
+
topic: notifications.role.legion-reviewer
|
|
30
|
+
message: ...
|
|
31
|
+
summary: Deployment needs confirmation.
|
|
32
|
+
message: "Confirm the listener health check passed.\n\nThen publish the release."
|
|
33
|
+
note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `to`: the local inbox that receives this delivery.
|
|
37
|
+
- `from`: the sending session's self-asserted id, with its session title in parentheses when the envelope carries one. Attribution and a direct-reply target, not an authenticated identity.
|
|
38
|
+
- `at`: the envelope timestamp; judge freshness by it.
|
|
39
|
+
- `id`: the delivery id; pass it as `in_reply_to` when you reply.
|
|
40
|
+
- `by`: the expiry deadline, when the sender set one.
|
|
41
|
+
- `urgency`: the sender's priority: `low`, `med`, `high` or `blocking`.
|
|
42
|
+
- `expects_reply`: `none`, `optional` or `required`.
|
|
43
|
+
- `re`: the delivery this one replies to. A Dispatch frame names it as a `dispatch://` ref: the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a document ask) or the message (`dispatch://KEY/message/<id>`), never the quoted text. On an ask, `dispatch.question` holds the start of the question's text; `dispatch read` gives the whole.
|
|
44
|
+
- `supersedes`: an earlier delivery this one replaces; act on this one in its place.
|
|
45
|
+
- `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes.
|
|
46
|
+
- `reply_role`: the role-publish reply call, when the sender holds a role.
|
|
47
|
+
- `summary`: the one-line source summary.
|
|
48
|
+
- `message`: the complete payload; it can hold several paragraphs.
|
|
49
|
+
- `note`: the payload names another session; never use that quoted id as the recipient.
|
|
50
|
+
- `unrecognised`: present when the payload failed validation or came from an unknown source. It names what failed, not every unknown key.
|
|
51
|
+
|
|
52
|
+
## Who receives a publish
|
|
53
|
+
|
|
54
|
+
It depends on where a session takes its deliveries, not on whether the topic is built in or your own.
|
|
55
|
+
|
|
56
|
+
- A session that holds its own NATS connection (every Oh My Pi session, and a Claude Code one) receives every message published on a subject it subscribes to, whoever sent it: its own publish included, and a subagent's publish to a topic the parent subscribes to. This holds for a topic the stream keeps and for a live team topic alike.
|
|
57
|
+
- A session the listener delivers to by push, over a serve port (such as the OpenCode plugin), does not receive its own publish, and receives no team topic at all, since it holds no NATS subscription of its own.
|
|
58
|
+
- A role is not a subject its holder subscribes to: the listener finds the live holder and forwards the message to it, and drops the message when that holder is the message's source session.
|
|
59
|
+
|
|
60
|
+
## Listener API errors
|
|
61
|
+
|
|
62
|
+
When you call the listener's `/v1` API directly, every error response is JSON, and when a field is at fault, `expected` names it: fix that field and retry.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Envoy in a Legion pane
|
|
2
|
+
|
|
3
|
+
Read this in a Legion pane before you touch a role claim, or when a Legion role message fails.
|
|
4
|
+
|
|
5
|
+
- The Legion plugin claims your assigned role at session start through a daemon-minted role token. Never call `envoy_role_set` and never subscribe to your role topic yourself, because the claim is already made (`packages/pi-shared/src/legion-claim-core.ts`).
|
|
6
|
+
- Never build a role token from an issue number or title: only the daemon maps tokens to issues.
|
|
7
|
+
- A Legion claim is last-claim-wins. It survives parking and worker re-creation, and stays through the issue's linger after close (the deployment's `linger_hours`).
|
|
8
|
+
- `no_holder`, `delivery_failed` and `receipt_timeout` on a Legion role are the daemon's liveness signals. Never add a second subscriber or retry by hand: the daemon revives or recreates the worker and re-delivers, or relaunches the root process, which gets a `catch-up` notice of its tree and reads the handoffs under `.legion/<issue>/`.
|
|
9
|
+
- `no_holder`: no live session holds the role.
|
|
10
|
+
- `delivery_failed`: the message was not forwarded at all: the holder lookup failed, the holder was stale, or the publish errored.
|
|
11
|
+
- `receipt_timeout`: the listener forwarded the message to a live holder and no receipt arrived in time. Its payload keeps `original_topic`, `event_id`, `payload` and the original `dedupe_key`, so the daemon can re-send it and the receiver's dedupe drops a copy that did arrive.
|
|
12
|
+
- Never forward these failure payloads to an architect, because the daemon already acts on them.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# How a role claim lives
|
|
2
|
+
|
|
3
|
+
Read this when a role you held is gone after a resume, fork or restart, when `envoy_role_set` is refused, or when a role publish returns `no holder`.
|
|
4
|
+
|
|
5
|
+
- A claim belongs to the session, not the process. `omp --resume` re-claims the role recorded in the session transcript, also after the listener removed the dead session's old row (a session's registration lapses 5 minutes after its last heartbeat).
|
|
6
|
+
- `/fork`, `/branch`, `/handoff` and any other switch that carries the transcript read the role the previous session id held and move it to the new id while the new session starts. `/new` and `/resume` install an unrelated transcript and carry nothing.
|
|
7
|
+
- A process that dies and is never resumed stops being a live holder. Once its registration lapses, role publishes return `no holder` until that transcript resumes or another session claims the role.
|
|
8
|
+
- An automatic reclaim never steals. It is a soft claim, which the listener grants only when the role is unheld, held by a session that is no longer live, or held by the id this session continues (a fork's parent). When a different live session holds it (the parent of a `/fork` whose role moved to the child, or a second process on the same transcript), the listener refuses atomically, and the resumed session logs a warning and holds nothing.
|
|
9
|
+
- Only an explicit `envoy_role_set` is last-claim-wins. Running it again for a role this session already holds is harmless.
|
|
10
|
+
- If another plugin made subscriptions that depend on your current role, `envoy_role_set` to a different role is refused, and the refusal names those subscriptions. `envoy_unsubscribe` keeps those subscriptions and lists them as kept.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Slack, Ghost Wispr and WhatsApp topics
|
|
2
|
+
|
|
3
|
+
Read this when you subscribe to one of these sources or test its routing.
|
|
4
|
+
|
|
5
|
+
## Slack
|
|
6
|
+
|
|
7
|
+
- Use the real team id (`T...`), never a workspace slug: the topic carries the id, so a slug receives nothing.
|
|
8
|
+
- Channel topics: `notifications.slack.<team>.<channel>.message` or `.mention`. One thread: `notifications.slack.<team>.<channel>.thread.<ts>.>`, with the dot in the thread timestamp written `_`.
|
|
9
|
+
- A delivery has a one-line prose `summary` and a structured `message`. The payload records `subtype` for edits, deletes and bot messages, `thread_ts` for replies, and `bot_id` and `bot_name` when a bot sent it.
|
|
10
|
+
- Reply in a thread with `slack_post`; its tool description gives the arguments and refusals.
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
envoy_subscribe(["notifications.slack.T01234567.C01234567.thread.1_000.>"])
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Ghost Wispr
|
|
17
|
+
|
|
18
|
+
Topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is `session.started`, `session.ended` or `summary.ready`. The summary is prose and the payload structured; A `summary.ready` payload carries `status`, `summary` (capped; `body_truncated` when cut), `summary_preset`, `timestamp` and `version`.
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
envoy_subscribe(["notifications.ghostwispr.session-example.summary.ready"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## WhatsApp
|
|
25
|
+
|
|
26
|
+
Topics are `notifications.whatsapp.<phone>.<jid>.message` or `.status`. A JID contains dots, which become extra NATS tokens, so match a chat with `>`, never `*`.
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
envoy_subscribe(["notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
To test Envoy routing without real WhatsApp ingestion, subscribe as above, then publish a synthetic message to the same `.message` topic:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
envoy_publish(
|
|
36
|
+
topic="notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
|
|
37
|
+
message="Synthetic WhatsApp routing test"
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The subscribed session should receive it. An Oh My Pi or Claude Code session receives its own publish, so it can run both steps; a session delivered over a serve port cannot, and needs a second session to publish. Real WhatsApp delivery also needs the listener's MCP bridge configured with a `whatsapp` server (`packages/envoy/internal/mcpbridge`).
|