@sjawhar/opencode-legion-envoy 0.8.0 → 0.10.0

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.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -25,7 +25,7 @@
25
25
  "skills"
26
26
  ],
27
27
  "scripts": {
28
- "build": "bun build src/server.ts bin/dispatch-mcp-shim.ts --outdir dist --target bun --format esm --external '@opencode-ai/*'",
28
+ "build": "bun build src/server.ts --root . --outdir dist --target bun --format esm --external '@opencode-ai/*'",
29
29
  "prepack": "bun run build && rm -rf skills && cp -r ../../skills skills",
30
30
  "typecheck": "bunx tsc --noEmit",
31
31
  "test": "bun test",
@@ -45,6 +45,7 @@
45
45
  "@legion/envoy-client": "0.1.0",
46
46
  "@types/bun": "latest",
47
47
  "solid-js": "^1.9.0",
48
- "typescript": "^5.3.0"
48
+ "typescript": "^5.3.0",
49
+ "zod": "^4.1.8"
49
50
  }
50
51
  }
package/skills/AGENTS.md CHANGED
@@ -8,6 +8,7 @@ event intake, process lifecycle, credentials, and role delivery.
8
8
 
9
9
  ```
10
10
  skills/
11
+ ├── dispatch/ # Raising a durable human question as a GitHub-issue thread
11
12
  ├── github/ # GitHub issue and pull-request operations
12
13
  ├── linear/ # Linear adapter instructions
13
14
  ├── legion-architect/ # Tree ownership, decomposition, gates, and scheduling
@@ -0,0 +1,245 @@
1
+ ---
2
+ name: dispatch
3
+ description: Use when you need a human decision from Sami or the user — asking a question, waiting on approval, saying "let me know", writing an end-of-message question block, picking between options with tradeoffs, or starting unattended/long-running work that will need input later. Raises the question as a durable GitHub-issue thread instead of a transcript question nobody will see.
4
+ ---
5
+
6
+ # Dispatch
7
+
8
+ `dispatch` turns a question into a durable, dashboard-visible GitHub-issue thread instead
9
+ of a line buried in a transcript. A thread is **one decision, held as a conversation**: it
10
+ opens with your question, the human replies, and if the reply changes the question you ask
11
+ again on the same thread. It closes when the decision is settled, not when the first reply
12
+ lands. Use it any time you would otherwise leave a question for a human to notice on their
13
+ own.
14
+
15
+ ## When to dispatch
16
+
17
+ - Any question not answerable at the keyboard in seconds.
18
+ - Anything decision-shaped: a fork in approach, a tradeoff only the human can weigh, an
19
+ approval gate.
20
+ - Anything you would otherwise write as an end-of-message question block and hope someone
21
+ reads.
22
+ - Before you park on a blocker. Never leave a session idle on an unasked question.
23
+ - Unattended or long-running work (Legion roles, background agents) that will need input
24
+ later — dispatch the question as soon as you know you'll need it, don't wait to be asked.
25
+
26
+ ## When NOT to dispatch
27
+
28
+ - A one-word clarification the human is actively typing with you right now, in the same
29
+ turn — use the built-in `ask` tool instead. `dispatch` is for questions the reader has
30
+ not seen your transcript for; `ask` is for questions inside a live conversation.
31
+ - Anything you can resolve yourself from tools or repo context. Dispatch is for genuine
32
+ human decisions, not a substitute for research.
33
+
34
+ ## Writing for the reader
35
+
36
+ The reader has not seen your transcript. They are looking at a card on a dashboard between
37
+ other things, and they will answer the question you wrote, not the one you meant.
38
+
39
+ - **No nouns you coined this session.** No "the list above", "those items", "lane B",
40
+ "the eval-set thing". No internal identifiers — eval-set ids, lane names, hashes,
41
+ session ids — unless the question is about them. Expand every identifier on first use.
42
+ GitHub references may be bare: `#482`, `acme-org/example-repo#17`, or a URL — the
43
+ dashboard unfurls them into their titles.
44
+ - **Structure over paragraphs.** `context` is at most three short paragraphs or a bullet
45
+ list, one idea each: what you are doing, what you found, why you are stuck. `question`
46
+ is a list: current state → desired state → your recommendation and why; the options
47
+ themselves go in `ask`, each tradeoff as its `description`. Don't just describe the
48
+ fork — say which branch you'd take and why.
49
+ - **Options are buttons.** If you are offering choices, put them in `ask`; never enumerate
50
+ them in prose. A human answers a button in one click and the answer arrives structured;
51
+ a choice buried in a paragraph arrives as a sentence you have to interpret.
52
+ - **Length caps.** `context` is at most 1200 characters, `question` at most 800. The
53
+ service refuses a longer call before anything reaches GitHub, naming the field and the
54
+ limit (`context is 1450 characters; the limit is 1200`); nothing is truncated. When you
55
+ hit a cap: move the choices into `ask`, cut background the reader does not need to
56
+ decide, and if there really are two decisions, open two threads.
57
+
58
+ ### Before / after
59
+
60
+ A real thread, as first written — one block, coined nouns, options in prose:
61
+
62
+ ```
63
+ subject: "e2e submitter"
64
+ context: "So I've been going through the e2e harness like we discussed and the submitter
65
+ identity thing from earlier is still broken because the fixture in lane B uses
66
+ the shared bot account which means the assertions in eval-set 7f3a can't tell
67
+ who submitted, and the thing I mentioned about the env var not being set in CI
68
+ is also true so basically the list above doesn't apply until we pick one, and I
69
+ looked at how the other repo does it and they hardcode it which is fine for them
70
+ but not us, also the token minting issue is related but separate."
71
+ question: "should I add E2E_SUBMITTER_EMAIL or use the bot or something else? I think
72
+ probably the env var but not sure, or maybe the hardcode thing, or the third
73
+ option where the harness reads it from git config. Let me know."
74
+ ```
75
+
76
+ The same decision, written for the reader:
77
+
78
+ ```
79
+ subject: "E2E harness: which identity submits test PRs?"
80
+ context: "- The end-to-end test harness (acme-org/example-repo#17158) submits PRs as the
81
+ shared bot account, so a run cannot tell test submissions apart from real ones.
82
+ - CI sets no submitter identity today; the harness has no setting for one."
83
+ question: "- Current: every test PR is authored by the shared bot.
84
+ - Desired: each run's PRs carry an identity that marks them as test submissions.
85
+ - Recommendation: a dedicated identity from one CI environment variable —
86
+ smallest change, no per-machine setup."
87
+ ask: [{ header: "Test submitter identity",
88
+ question: "Which identity should the harness use?",
89
+ options: [
90
+ { label: "Env var E2E_SUBMITTER_EMAIL", description: "one CI variable; the harness reads it" },
91
+ { label: "Shared bot (status quo)", description: "no change; runs stay indistinguishable" },
92
+ { label: "Read from git config", description: "per-machine setup; drifts" } ] }]
93
+ ```
94
+
95
+ ## How
96
+
97
+ ### Opening a thread
98
+
99
+ ```
100
+ dispatch({
101
+ subject: "Postgres migration: run online or take a maintenance window?",
102
+ context: "- Implementing the users-table index migration for #482.
103
+ - The table has 40M rows; a plain CREATE INDEX locks writes for ~6 minutes
104
+ in the staging timing test I just ran.",
105
+ question: "- Current: no online-migration tooling in this repo's deploy pipeline.
106
+ - Desired: index added without a customer-visible write outage.
107
+ - Recommendation: the maintenance window — smaller blast radius, no
108
+ migration-runner change, inside the existing SLA.",
109
+ ask: [{ header: "Approach", question: "Which way?",
110
+ options: [
111
+ { label: "CREATE INDEX CONCURRENTLY", description: "no lock, ~25 min, needs a migration-runner change" },
112
+ { label: "6-minute window at 03:00 UTC", description: "runner unchanged, brief write outage" } ] }],
113
+ urgency: "med"
114
+ })
115
+ ```
116
+
117
+ - `subject`: one line, the decision — this is the issue title and the dashboard row.
118
+ - `context` (required, ≤ 1200 chars): what you are doing, what you found, why you are stuck.
119
+ - `question` (required, ≤ 800 chars): current → desired → recommendation (options live in
120
+ `ask`).
121
+ - `ask` (optional): a list of `{ question, header?, options: [{ label, description? }],
122
+ multiple?, custom? }`. These render as buttons, always with an "Other (specify)"
123
+ free-text field beside them (`custom` does not change this); a plain reply on the thread
124
+ is also possible but arrives unstructured.
125
+ - `urgency` (optional, default `med`): `low` — whenever convenient; `med` — needed today;
126
+ `high` — something will stall soon; `blocking` — you cannot proceed at all right now.
127
+ - `repo` (optional): only to target a **different** repo than your working directory's
128
+ GitHub remote; the tool fills it from the cwd otherwise.
129
+ - `parent` (optional): only when a real issue exists to attach to — `<n>` in the resolved
130
+ repo or `owner/name#<n>` elsewhere; `owner/name#<n>#<commentId>` also appends a
131
+ breadcrumb to that comment. For Legion roles, `parent` is the root issue you're working
132
+ under. Omitting it creates a standalone thread; don't invent a parent.
133
+
134
+ The tool returns `{"thread": N, "url": "..."}`; that URL is the durable record.
135
+
136
+ ### Continuing a thread
137
+
138
+ When the reply changes the question — the human challenged the premise, ruled out your
139
+ recommendation, or asked for a variant you had not offered — re-ask **on the same thread**:
140
+
141
+ ```
142
+ dispatch({
143
+ thread: "482", // or "owner/name#482"
144
+ context: "- You ruled out the maintenance window: the 03:00 UTC slot is now used by
145
+ the nightly export.
146
+ - CONCURRENTLY needs the migration runner to run outside a transaction.",
147
+ question: "- Current: the runner wraps every migration in a transaction.
148
+ - Desired: this one index built online.
149
+ - Recommendation: a one-off flag on the runner for non-transactional
150
+ migrations, used only by this migration.",
151
+ ask: [{ header: "Runner change", question: "Add the non-transactional flag?",
152
+ options: [{ label: "Yes, one-off flag" }, { label: "No, hand-run the index" }] }]
153
+ })
154
+ ```
155
+
156
+ - `thread` replaces `subject`; `urgency`, `repo`, and `parent` are not accepted with it.
157
+ The thread must be open and be a dispatch thread; otherwise the tool says so
158
+ (`#N is not a dispatch thread`, `#N is closed; open a new thread`).
159
+ - Same decision → same thread. A genuinely new decision → a new thread, even if it came
160
+ up in the reply.
161
+ - **Read a challenge as an answer.** "Why not the bot?" is not a request for more prose;
162
+ it is the human declining your framing. Say what you now know, then ask the narrowed
163
+ question with new options — unless the challenge itself decides ("Why not the bot? Just
164
+ use the bot."), in which case act on it and re-ask nothing. A follow-up carries your
165
+ session's identity, so the human sees which conversation is asking even after a handoff.
166
+ - The tool returns `{"thread": N, "url": "...", "comment": "..."}`.
167
+
168
+ ## After dispatching
169
+
170
+ The reply arrives back in your own session as a steer — you do not fetch it. Keep every
171
+ other non-blocked lane of work moving while you wait; a dispatch is not a reason to go
172
+ idle. Never poll GitHub for the reply and never set up a watcher, cron, or retry loop to
173
+ check on it — the delivery mechanism is already push-based. Each answer names the question
174
+ it answers, so a thread with two open questions delivers two steers.
175
+
176
+ If the tool errors because it can't resolve a repo (cwd has no GitHub remote and no
177
+ qualified `parent` or `thread`), pass `repo=owner/name` (opening) or
178
+ `thread=owner/name#<n>` (continuing) and retry.
179
+
180
+ **Named risk:** a thread created in a repo where the Envoy GitHub App is not installed
181
+ gets created successfully, but replies to it never route back to your session — you'll
182
+ wait forever for a steer that can't arrive. The App is installed per account and per repo,
183
+ not org-wide, and coverage changes; before relying on a reply from a repo you have not
184
+ dispatched to before, check it: `gh api /repos/<owner>/<name>/installation` returns 200 when
185
+ the App is installed there and 404 when it is not.
186
+
187
+ ## Manual fallback: the marker format
188
+
189
+ If the tool itself is unavailable, the dashboard still understands a hand-written turn.
190
+ Every dispatch marker is an HTML comment at the very start of the body — invisible on
191
+ GitHub — followed by a blank line and the readable text.
192
+
193
+ A follow-up question, posted with `gh issue comment <n> --body-file <file>`:
194
+
195
+ ```
196
+ <!-- dispatch:ask
197
+ requestId: <16 lowercase hex chars, unique to this follow-up>
198
+ ask:
199
+ - askId: <the same 16 chars>
200
+ question: <the question>
201
+ header: <short header>
202
+ options:
203
+ - label: <label>
204
+ description: <one line>
205
+ -->
206
+
207
+ ## Context
208
+
209
+ <context>
210
+
211
+ ## Question
212
+
213
+ <question>
214
+ ```
215
+
216
+ A new thread, posted with `gh issue create --label dispatch-thread --title "<subject>" --body-file <file>`:
217
+
218
+ ```
219
+ <!-- dispatch:thread
220
+ requestId: <16 lowercase hex chars>
221
+ urgency: med
222
+ ask:
223
+ - askId: <the same 16 chars>
224
+ question: <the question>
225
+ options:
226
+ - label: <label>
227
+ -->
228
+
229
+ **<subject>**
230
+
231
+ ## Context
232
+
233
+ <context>
234
+
235
+ ## Question
236
+
237
+ <question>
238
+ ```
239
+
240
+ `ask` may be omitted when there are no buttons to offer. A second ask on the same turn has
241
+ `askId: <requestId>.1`, a third `.2`. A hand-posted turn carries no `origin`, so the
242
+ dashboard cannot say which session asked, and nothing subscribes your session to a
243
+ hand-created thread — subscribe it yourself with `envoy_subscribe` to
244
+ `notifications.github.<owner>.<name>.issue.<n>.>` or the reply never arrives. Prefer the
245
+ tool.
@@ -119,9 +119,9 @@ GitHub topics are **resource-scoped** — every event includes the resource type
119
119
 
120
120
  Examples:
121
121
 
122
- - `notifications.github.trajectory-labs-pbc.agent-c.pr.9880` (PR #9880 state changes)
123
- - `notifications.github.trajectory-labs-pbc.agent-c.pr.9880.comment` (comments on PR #9880)
124
- - `notifications.github.trajectory-labs-pbc.agent-c.issue.9909.>` (all events on issue #9909)
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
125
  - `notifications.github.sjawhar.legion.pr.>` (all PR events across all PRs)
126
126
  - `notifications.github.sjawhar.legion.mention` (all @mentions repo-wide)
127
127
  - `notifications.github.sjawhar.legion.push.branch.main` (pushes to main)
@@ -143,10 +143,10 @@ Thread timestamps are normalized: `1234567890.123456` → `1234567890_123456`
143
143
 
144
144
  Examples:
145
145
 
146
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.message`
147
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention`
148
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.message`
149
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.mention`
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
150
 
151
151
  ### Ghost Wispr
152
152
 
@@ -200,6 +200,14 @@ Examples:
200
200
 
201
201
  You do NOT need to subscribe in order to send or publish.
202
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
+
203
211
  ### To wait for CI, PR checks, or other async work
204
212
 
205
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.
@@ -226,7 +234,7 @@ If you have nothing else to do, end the response. The user is not your alarm clo
226
234
 
227
235
  ```text
228
236
  envoy_subscribe([
229
- "notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
237
+ "notifications.slack.T01234567.C0A0DHVU8HE.mention"
230
238
  ])
231
239
  ```
232
240
 
@@ -234,7 +242,7 @@ envoy_subscribe([
234
242
 
235
243
  ```text
236
244
  envoy_subscribe([
237
- "notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.>"
245
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.>"
238
246
  ])
239
247
  ```
240
248
 
@@ -242,7 +250,7 @@ envoy_subscribe([
242
250
 
243
251
  ```text
244
252
  envoy_subscribe([
245
- "notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.message"
253
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message"
246
254
  ])
247
255
  ```
248
256
 
@@ -250,23 +258,23 @@ envoy_subscribe([
250
258
 
251
259
  ```text
252
260
  envoy_subscribe([
253
- "notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.>"
261
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
254
262
  ])
255
263
  ```
256
264
 
257
- ### Subscribe to all PR events for agent-c
265
+ ### Subscribe to all PR events for example-repo
258
266
 
259
267
  ```text
260
268
  envoy_subscribe([
261
- "notifications.github.trajectory-labs-pbc.agent-c.pr.>"
269
+ "notifications.github.example-org.example-repo.pr.>"
262
270
  ])
263
271
  ```
264
272
 
265
- ### Subscribe controller to GitHub @mentions for agent-c
273
+ ### Subscribe controller to GitHub @mentions for example-repo
266
274
 
267
275
  ```text
268
276
  envoy_subscribe([
269
- "notifications.github.trajectory-labs-pbc.agent-c.mention"
277
+ "notifications.github.example-org.example-repo.mention"
270
278
  ])
271
279
  ```
272
280
 
@@ -318,7 +326,7 @@ Catches all conversations and event kinds for the specified phone number.
318
326
  - Different sessions can subscribe to different channels/repos
319
327
  - Agent-to-agent delivery uses exact session IDs
320
328
  - `envoy_list()` distinguishes `live`, `registry`, and `both`; a `live` topic is receiving now even when the listener registry has not caught up.
321
- - For Slack, use the real `team_id` in topics (for example `T09FRELLTS8`), not a workspace slug like `trajectorylabs`
329
+ - For Slack, use the real `team_id` in topics (for example `T01234567`), not a workspace slug like `acme`
322
330
  - GitHub mention routing is body-based because GitHub has no dedicated app mention webhook event
323
331
 
324
332
  ## Synthetic Smoke Test (WhatsApp — NATS Routing Only)
@@ -62,7 +62,8 @@ legion({ op: "label_add", issue: "<root issue>", label: "needs-approval" })
62
62
  dispatch({
63
63
  parent: "<root issue>",
64
64
  subject: "Legion design approval requested",
65
- body: "<summary, specification, and requested decision>"
65
+ context: "<what the tree is, what triggered the gate>",
66
+ question: "<specification summary and the decision requested>"
66
67
  })
67
68
  ```
68
69
 
package/src/server.ts CHANGED
@@ -3,13 +3,20 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { agentSubject } from "@legion/contracts";
5
5
  import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
6
+ import { executeDispatch } from "@legion/envoy-client/dispatch-call";
7
+ import { resolveDispatchConfig } from "@legion/envoy-client/dispatch-config";
8
+ import {
9
+ DISPATCH_ARGUMENTS,
10
+ DISPATCH_TOOL_DESCRIPTION,
11
+ DISPATCH_TOOL_NAME,
12
+ DISPATCH_URGENCIES,
13
+ parseDispatchCall,
14
+ } from "@legion/envoy-client/dispatch-contract";
6
15
  import { dispatchSubscriptionTopic } from "@legion/envoy-client/dispatch-subscribe";
7
16
  import { machineID } from "@legion/envoy-client/machine";
8
17
  import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
9
18
  import { createEnvoyClient } from "@legion/envoy-client/transport";
10
19
  import { tool } from "@opencode-ai/plugin/tool";
11
- import { loadEnvoyConfig } from "./config";
12
- import { buildDispatchMcpEntry, injectEnvoyMcp } from "./dispatch-mcp";
13
20
  import { logger } from "./log";
14
21
  import { resolvePort } from "./port";
15
22
 
@@ -36,9 +43,40 @@ const [
36
43
  sessionsSpec,
37
44
  ] = envoyToolSpecs;
38
45
 
46
+ // The dispatch tool's LLM-facing schema, built with OpenCode's own zod
47
+ // (`tool.schema`): OpenCode converts plugin schemas with that instance, and a
48
+ // schema from another zod build loses its checks and descriptions on the way.
49
+ // It mirrors `dispatchToolShape` from the contract field for field — the test
50
+ // suite compares the two as JSON Schema so they cannot drift apart.
51
+ const dispatchQuestionOption = tool.schema.strictObject({
52
+ label: tool.schema.string().min(1),
53
+ description: tool.schema.string().optional(),
54
+ });
55
+ const dispatchQuestion = tool.schema.strictObject({
56
+ question: tool.schema.string().min(1),
57
+ header: tool.schema.string().optional(),
58
+ options: tool.schema.array(dispatchQuestionOption).optional(),
59
+ multiple: tool.schema.boolean().optional(),
60
+ custom: tool.schema.boolean().optional(),
61
+ });
62
+ const dispatchArgs = {
63
+ subject: tool.schema.string().describe(DISPATCH_ARGUMENTS.subject).optional(),
64
+ thread: tool.schema.string().describe(DISPATCH_ARGUMENTS.thread).optional(),
65
+ context: tool.schema.string().describe(DISPATCH_ARGUMENTS.context),
66
+ question: tool.schema.string().describe(DISPATCH_ARGUMENTS.question),
67
+ ask: tool.schema.array(dispatchQuestion).describe(DISPATCH_ARGUMENTS.ask).optional(),
68
+ urgency: tool.schema.enum(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
69
+ repo: tool.schema.string().describe(DISPATCH_ARGUMENTS.repo).optional(),
70
+ parent: tool.schema.string().describe(DISPATCH_ARGUMENTS.parent).optional(),
71
+ };
72
+
39
73
  export default async (input: { serverUrl: URL }) => {
40
74
  const cwd = process.cwd();
41
- const config = await loadEnvoyConfig(cwd);
75
+ // One loader for the shared envoy.json contract. An invalid file refuses to
76
+ // load rather than run with dispatch silently off.
77
+ const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
78
+ if (dispatchConfig.error !== null) throw new Error(`[envoy-plugin] ${dispatchConfig.error}`);
79
+ const dispatchServiceUrl = dispatchConfig.url;
42
80
  const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
43
81
  const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
44
82
  let activeSessionID: string | null = null;
@@ -153,13 +191,35 @@ export default async (input: { serverUrl: URL }) => {
153
191
  clearInterval(heartbeatInterval);
154
192
  });
155
193
 
194
+ // The dispatch tool is present iff envoy.json (or DISPATCH_MCP_URL) names a
195
+ // service. The plugin adds only what it alone knows — that this is OpenCode,
196
+ // and which session is asking.
197
+ const dispatchTool =
198
+ dispatchServiceUrl === null
199
+ ? {}
200
+ : {
201
+ [DISPATCH_TOOL_NAME]: tool({
202
+ description: DISPATCH_TOOL_DESCRIPTION,
203
+ args: dispatchArgs,
204
+ async execute(args, ctx) {
205
+ ctx.metadata({ title: "Dispatch" });
206
+ // Validate before the title lookup: an invalid call costs no request.
207
+ const call = parseDispatchCall(args);
208
+ const result = await executeDispatch({
209
+ call,
210
+ cwd: ctx.directory,
211
+ host: "opencode",
212
+ sessionId: ctx.sessionID,
213
+ sessionTitle: (await fetchTitle(ctx.sessionID)) ?? undefined,
214
+ serviceUrl: dispatchServiceUrl,
215
+ });
216
+ return JSON.stringify(result);
217
+ },
218
+ }),
219
+ };
220
+
156
221
  return {
157
- config: (
158
- cfg: { mcp?: Record<string, unknown>; skills?: { paths?: string[] } } & Record<
159
- string,
160
- unknown
161
- >
162
- ) => {
222
+ config: (cfg: { skills?: { paths?: string[] } } & Record<string, unknown>) => {
163
223
  // Serve the bundled legion skills to every session on this serve.
164
224
  if (skillsDirectory) {
165
225
  cfg.skills ??= {};
@@ -168,20 +228,6 @@ export default async (input: { serverUrl: URL }) => {
168
228
  cfg.skills.paths.push(skillsDirectory);
169
229
  }
170
230
  }
171
- // Inject the envoy MCP entry into the OpenCode config when
172
- // dispatch is enabled. Centralizing this in the plugin (instead of
173
- // each user's opencode.json) means:
174
- // 1. Registration is gated by `dispatch.enabled`
175
- // 2. The bearer token is sourced per-CWD via the user's gh shim
176
- // (no env coordination needed)
177
- // 3. Token rotation happens transparently inside the shim
178
- // subprocess — OpenCode never sees an expired token
179
- const entry = buildDispatchMcpEntry({
180
- dispatch: config.dispatch,
181
- });
182
- if (!entry) return;
183
- const { warning } = injectEnvoyMcp(cfg, entry);
184
- if (warning) logger.warn(warning);
185
231
  },
186
232
  event: async ({
187
233
  event,
@@ -250,9 +296,9 @@ export default async (input: { serverUrl: URL }) => {
250
296
  input: { tool: string; sessionID: string; callID: string; args: unknown },
251
297
  output: { title: string; output: string; metadata: unknown }
252
298
  ) => {
253
- // When this session opens a Dispatch thread via the envoy_dispatch MCP
254
- // tool, auto-subscribe it to the thread's GitHub topic so the human's
255
- // reply is delivered back through Envoy. Best-effort — a subscribe
299
+ // When this session opens or continues a Dispatch thread via the native
300
+ // dispatch tool, auto-subscribe it to the thread's GitHub topic so the
301
+ // human's reply is delivered back through Envoy. Best-effort — a subscribe
256
302
  // failure must never surface to the model or fail the tool call.
257
303
  const topic = dispatchSubscriptionTopic(input.tool, output.output);
258
304
  if (!topic) return;
@@ -276,6 +322,7 @@ export default async (input: { serverUrl: URL }) => {
276
322
  clearInterval(heartbeatInterval);
277
323
  },
278
324
  tool: {
325
+ ...dispatchTool,
279
326
  envoy_subscribe: tool({
280
327
  description: subscribeSpec.description,
281
328
  args: { topics: tool.schema.array(tool.schema.string()) },