@sjawhar/opencode-legion-envoy 0.9.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.9.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
  }
@@ -6,8 +6,11 @@ description: Use when you need a human decision from Sami or the user — asking
6
6
  # Dispatch
7
7
 
8
8
  `dispatch` turns a question into a durable, dashboard-visible GitHub-issue thread instead
9
- of a line buried in a transcript. Use it any time you would otherwise leave a question for
10
- a human to notice on their own.
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.
11
14
 
12
15
  ## When to dispatch
13
16
 
@@ -28,67 +31,215 @@ a human to notice on their own.
28
31
  - Anything you can resolve yourself from tools or repo context. Dispatch is for genuine
29
32
  human decisions, not a substitute for research.
30
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
+
31
95
  ## How
32
96
 
97
+ ### Opening a thread
98
+
33
99
  ```
34
100
  dispatch({
35
101
  subject: "Postgres migration: run online or take a maintenance window?",
36
- context: "Implementing the users-table index migration for issue #482. The table has
37
- 40M rows; a plain CREATE INDEX locks writes for ~6 minutes in the staging
38
- timing test I just ran.",
39
- question: "Current: no online-migration tooling wired into this repo's deploy pipeline.
40
- Desired: index added without a customer-visible write outage.
41
- Option A CREATE INDEX CONCURRENTLY, no lock, ~25 min, can't run inside a
42
- transaction (rules out the existing migration runner without a change).
43
- Option B take a 6-minute maintenance window during the 03:00 UTC low-traffic
44
- slot, keep the existing runner unchanged.
45
- Recommendation: B smaller blast radius, no migration-runner change, and the
46
- window is well inside the existing maintenance SLA.",
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" } ] }],
47
113
  urgency: "med"
48
114
  })
49
115
  ```
50
116
 
51
117
  - `subject`: one line, the decision — this is the issue title and the dashboard row.
52
- - `context` (required): what you are doing, what you found, why you are stuck. **The
53
- reader has not seen your transcript** never write "the list above," "those items," or
54
- anything that assumes shared context. State it fresh.
55
- - `question` (required): current state desired state proposed change, then options
56
- with tradeoffs, then your own recommendation. Don't just describe the fork — say which
57
- branch you'd take and why.
58
- - `ask` (optional): a list of `{ question, header?, options: [{ label, description? }] }`
59
- when the decision is a discrete set of choices. These render as buttons on the dashboard
60
- use this instead of asking the human to type a free-text answer when the answer really
61
- is "pick one of N."
62
- - `urgency` (optional, default `med`):
63
- - `low` whenever it's convenient, no deadline pressure.
64
- - `med` needed today.
65
- - `high` blocking something that will stall soon if unanswered.
66
- - `blocking` you cannot proceed at all right now.
67
- - `repo` (optional): only pass this to target a **different** repo than your current
68
- working directory's GitHub remote. The shim fills `repo` from cwd automatically
69
- omit it in the common case.
70
- - `parent` (optional): only set this when a real issue already exists to attach to —
71
- `<n>` for an issue in the resolved repo, or `owner/name#<n>` to point at a specific
72
- issue in another repo. For Legion roles, `parent` is the root issue you're working
73
- under. Omitting it creates a standalone top-level thread; don't invent a parent that
74
- doesn't exist.
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": "..."}`.
75
167
 
76
168
  ## After dispatching
77
169
 
78
170
  The reply arrives back in your own session as a steer — you do not fetch it. Keep every
79
171
  other non-blocked lane of work moving while you wait; a dispatch is not a reason to go
80
172
  idle. Never poll GitHub for the reply and never set up a watcher, cron, or retry loop to
81
- check on it — the delivery mechanism is already push-based. The issue URL returned by the
82
- tool (`{"thread": N, "url": "..."}`) is the durable record; you don't need to keep it
83
- anywhere else.
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.
84
175
 
85
176
  If the tool errors because it can't resolve a repo (cwd has no GitHub remote and no
86
- qualified `parent`), pass `repo=owner/name` explicitly and retry.
177
+ qualified `parent` or `thread`), pass `repo=owner/name` (opening) or
178
+ `thread=owner/name#<n>` (continuing) and retry.
87
179
 
88
180
  **Named risk:** a thread created in a repo where the Envoy GitHub App is not installed
89
181
  gets created successfully, but replies to it never route back to your session — you'll
90
- wait forever for a steer that can't arrive. Coverage is every repo under `sjawhar`, plus
91
- `acme-org/example-repo` and `acme-org/example-e2e` the App is
92
- deliberately not installed org-wide. That list can change; to check a repo before relying
93
- on a reply, `gh api /repos/<owner>/<name>/installation` returns 200 when the App is
94
- installed there and 404 when it is not.
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.
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()) },