@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/README.md +13 -0
- package/dist/src/server.js +807 -166
- package/package.json +4 -3
- package/skills/dispatch/SKILL.md +196 -45
- package/src/server.ts +73 -26
- package/dist/bin/dispatch-mcp-shim.js +0 -4889
- package/src/config/index.ts +0 -69
- package/src/config/schema.ts +0 -21
- package/src/dispatch-mcp.ts +0 -113
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/opencode-legion-envoy",
|
|
3
|
-
"version": "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
|
|
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/dispatch/SKILL.md
CHANGED
|
@@ -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.
|
|
10
|
-
|
|
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
|
|
37
|
-
40M rows; a plain CREATE INDEX locks writes for ~6 minutes
|
|
38
|
-
|
|
39
|
-
question: "Current: no online-migration tooling
|
|
40
|
-
Desired: index added without a customer-visible write outage.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- `
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
—
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
82
|
-
|
|
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`
|
|
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.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
// tool, auto-subscribe it to the thread's GitHub topic so the
|
|
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()) },
|