@sjawhar/opencode-legion-envoy 0.1.10 → 0.2.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/AGENTS.md +3 -2
- package/bin/dispatch-mcp-shim.ts +75 -0
- package/package.json +18 -5
- package/scripts/sync-host.sh +18 -1
- package/src/__tests__/clipboard.test.ts +56 -0
- package/src/__tests__/dispatch-mcp-bridge.test.ts +339 -0
- package/src/__tests__/dispatch-mcp.test.ts +116 -0
- package/src/__tests__/dispatch-subscribe.test.ts +66 -0
- package/src/__tests__/index.test.ts +433 -8
- package/src/__tests__/log.test.ts +135 -0
- package/src/__tests__/tui-port.test.ts +85 -0
- package/src/clipboard.ts +140 -0
- package/src/config/__tests__/index.test.ts +93 -0
- package/src/config/index.ts +62 -0
- package/src/config/schema.ts +26 -0
- package/src/dispatch-mcp-bridge.ts +310 -0
- package/src/dispatch-mcp.ts +102 -0
- package/src/dispatch-subscribe.ts +52 -0
- package/src/log.ts +86 -0
- package/src/{index.ts → server.ts} +168 -45
- package/src/tui-port.ts +83 -0
- package/src/tui.tsx +122 -0
- package/tsconfig.json +3 -1
- package/dist/index.js +0 -12633
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Core forwarding logic for the local MCP shim that proxies opencode's
|
|
2
|
+
// stdio MCP traffic to the remote dispatch server's Streamable HTTP /mcp,
|
|
3
|
+
// minting a fresh GitHub bearer per request via the user's `gh` shim.
|
|
4
|
+
//
|
|
5
|
+
// Exposed as a library so the bridge can be unit-tested without spawning
|
|
6
|
+
// a real subprocess. The CLI wrapper in bin/dispatch-mcp-shim.ts wires
|
|
7
|
+
// this to stdin/stdout.
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
/** Refresh well before the 1h gh-app installation token expiry. */
|
|
15
|
+
const DEFAULT_TOKEN_CACHE_TTL_MS = 50 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export interface JsonRpcRequest {
|
|
18
|
+
jsonrpc: "2.0";
|
|
19
|
+
id?: string | number;
|
|
20
|
+
method: string;
|
|
21
|
+
params?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JsonRpcResponse {
|
|
25
|
+
jsonrpc: "2.0";
|
|
26
|
+
id: string | number | null;
|
|
27
|
+
result?: unknown;
|
|
28
|
+
error?: { code: number; message: string; data?: unknown };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TokenGetter = () => Promise<string | null>;
|
|
32
|
+
export type FetchImpl = typeof fetch;
|
|
33
|
+
|
|
34
|
+
export interface BridgeOptions {
|
|
35
|
+
remoteUrl: string;
|
|
36
|
+
getToken: TokenGetter;
|
|
37
|
+
fetchImpl?: FetchImpl;
|
|
38
|
+
tokenCacheTtlMs?: number;
|
|
39
|
+
/** Optional logger for stderr-side diagnostics. */
|
|
40
|
+
logError?: (msg: string) => void;
|
|
41
|
+
/** Optional clock injection for tests. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Bridge {
|
|
46
|
+
handle(request: JsonRpcRequest): Promise<JsonRpcResponse | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const defaultGhTokenGetter: TokenGetter = async () => {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync("gh", ["auth", "token"], {
|
|
52
|
+
timeout: 5_000,
|
|
53
|
+
});
|
|
54
|
+
const value = stdout.trim();
|
|
55
|
+
return value.length > 0 ? value : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a Streamable-HTTP SSE response and return the first `event: message`
|
|
63
|
+
* payload as parsed JSON. Returns null if no message line was found.
|
|
64
|
+
*/
|
|
65
|
+
function parseSseBody(body: string): unknown {
|
|
66
|
+
for (const line of body.split("\n")) {
|
|
67
|
+
const match = line.match(/^data:\s*(.+)$/);
|
|
68
|
+
if (match) {
|
|
69
|
+
return JSON.parse(match[1] as string);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect whether a parsed JSON-RPC response indicates that the *upstream*
|
|
77
|
+
* GitHub call failed with 401 (e.g. expired App-installation token).
|
|
78
|
+
*
|
|
79
|
+
* The Go MCP server forwards GitHub errors verbatim in the tool result
|
|
80
|
+
* (`result.isError: true`, content text contains "401 Bad credentials")
|
|
81
|
+
* or as a JSON-RPC error message. Either signal triggers a one-shot retry
|
|
82
|
+
* with a freshly-minted token.
|
|
83
|
+
*/
|
|
84
|
+
function hasUpstreamUnauthorized(parsed: unknown): boolean {
|
|
85
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
86
|
+
const obj = parsed as { error?: { message?: unknown }; result?: unknown };
|
|
87
|
+
if (
|
|
88
|
+
obj.error &&
|
|
89
|
+
typeof obj.error.message === "string" &&
|
|
90
|
+
containsUnauthorized(obj.error.message)
|
|
91
|
+
) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const result = obj.result as { isError?: unknown; content?: unknown } | undefined;
|
|
95
|
+
if (!result || result.isError !== true || !Array.isArray(result.content)) return false;
|
|
96
|
+
for (const item of result.content) {
|
|
97
|
+
if (
|
|
98
|
+
item &&
|
|
99
|
+
typeof item === "object" &&
|
|
100
|
+
"text" in item &&
|
|
101
|
+
typeof (item as { text: unknown }).text === "string"
|
|
102
|
+
) {
|
|
103
|
+
if (containsUnauthorized((item as { text: string }).text)) return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function containsUnauthorized(msg: string): boolean {
|
|
110
|
+
return /\b401\b/.test(msg) && /bad credentials|unauthorized/i.test(msg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Collapse JSON-Schema union `type` arrays (e.g. { type: ["null", "array"] }) into a
|
|
115
|
+
* single-type schema. Google Gemini's function-declaration validator rejects union-type
|
|
116
|
+
* arrays: the array branch loses its `items` and the top-level `items` is left orphaned,
|
|
117
|
+
* producing `any_of[0].items: missing field`. Remote dispatch tools express nullable fields
|
|
118
|
+
* this way, so we normalize them in transit. Dropping "null" is safe — the model omits or
|
|
119
|
+
* passes a real value, and `required` already governs presence.
|
|
120
|
+
*/
|
|
121
|
+
function normalizeSchemaUnionTypes(node: unknown): unknown {
|
|
122
|
+
if (Array.isArray(node)) return node.map(normalizeSchemaUnionTypes);
|
|
123
|
+
if (!node || typeof node !== "object") return node;
|
|
124
|
+
const result: Record<string, unknown> = {};
|
|
125
|
+
for (const [key, value] of Object.entries(node)) {
|
|
126
|
+
result[key] = normalizeSchemaUnionTypes(value);
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(result.type)) {
|
|
129
|
+
const nonNull = result.type.filter((t) => t !== "null");
|
|
130
|
+
if (nonNull.length === 1) {
|
|
131
|
+
result.type = nonNull[0];
|
|
132
|
+
} else if (nonNull.length === 0) {
|
|
133
|
+
result.type = "null";
|
|
134
|
+
} else {
|
|
135
|
+
// Multiple non-null types: express as anyOf, carrying items into the array branch
|
|
136
|
+
// so no branch is left itemless.
|
|
137
|
+
const items = result.items;
|
|
138
|
+
delete result.items;
|
|
139
|
+
result.anyOf = nonNull.map((t) =>
|
|
140
|
+
t === "array" && items != null ? { type: t, items } : { type: t }
|
|
141
|
+
);
|
|
142
|
+
delete result.type;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalize tool input schemas in a `tools/list` response so downstream providers
|
|
150
|
+
* (notably Gemini) accept them. No-op for any other response shape.
|
|
151
|
+
*/
|
|
152
|
+
function normalizeToolsListResponse(response: JsonRpcResponse | null): JsonRpcResponse | null {
|
|
153
|
+
if (!response || typeof response.result !== "object" || response.result === null) return response;
|
|
154
|
+
const result = response.result as { tools?: unknown };
|
|
155
|
+
if (!Array.isArray(result.tools)) return response;
|
|
156
|
+
const tools = result.tools.map((entry) => {
|
|
157
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
158
|
+
const tool = entry as Record<string, unknown>;
|
|
159
|
+
if (tool.inputSchema == null || typeof tool.inputSchema !== "object") return tool;
|
|
160
|
+
return { ...tool, inputSchema: normalizeSchemaUnionTypes(tool.inputSchema) };
|
|
161
|
+
});
|
|
162
|
+
return { ...response, result: { ...result, tools } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Apply response normalization that depends on the request method. */
|
|
166
|
+
function finalizeResponse(
|
|
167
|
+
request: JsonRpcRequest,
|
|
168
|
+
response: JsonRpcResponse | null
|
|
169
|
+
): JsonRpcResponse | null {
|
|
170
|
+
return request.method === "tools/list" ? normalizeToolsListResponse(response) : response;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createBridge(opts: BridgeOptions): Bridge {
|
|
174
|
+
const remoteUrl = opts.remoteUrl;
|
|
175
|
+
const getToken = opts.getToken;
|
|
176
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
177
|
+
const ttl = opts.tokenCacheTtlMs ?? DEFAULT_TOKEN_CACHE_TTL_MS;
|
|
178
|
+
const now = opts.now ?? Date.now;
|
|
179
|
+
const log = opts.logError ?? ((m) => process.stderr.write(`${m}\n`));
|
|
180
|
+
|
|
181
|
+
let cachedToken: { value: string; fetchedAt: number } | null = null;
|
|
182
|
+
let sessionId: string | null = null;
|
|
183
|
+
|
|
184
|
+
async function token(force: boolean): Promise<string | null> {
|
|
185
|
+
if (!force && cachedToken && now() - cachedToken.fetchedAt < ttl) {
|
|
186
|
+
return cachedToken.value;
|
|
187
|
+
}
|
|
188
|
+
const value = await getToken();
|
|
189
|
+
if (value) {
|
|
190
|
+
cachedToken = { value, fetchedAt: now() };
|
|
191
|
+
} else {
|
|
192
|
+
cachedToken = null;
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function errorResponse(
|
|
198
|
+
id: string | number | null | undefined,
|
|
199
|
+
code: number,
|
|
200
|
+
message: string
|
|
201
|
+
): JsonRpcResponse | null {
|
|
202
|
+
if (id === undefined || id === null) return null;
|
|
203
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function attempt(
|
|
207
|
+
request: JsonRpcRequest,
|
|
208
|
+
forceRefresh: boolean
|
|
209
|
+
): Promise<
|
|
210
|
+
| { kind: "ok"; response: JsonRpcResponse | null }
|
|
211
|
+
| { kind: "retry" }
|
|
212
|
+
| { kind: "err"; response: JsonRpcResponse | null }
|
|
213
|
+
> {
|
|
214
|
+
const bearer = await token(forceRefresh);
|
|
215
|
+
if (!bearer) {
|
|
216
|
+
return {
|
|
217
|
+
kind: "err",
|
|
218
|
+
response: errorResponse(
|
|
219
|
+
request.id,
|
|
220
|
+
-32000,
|
|
221
|
+
"envoy-dispatch shim: gh auth token returned empty — check your gh-app setup"
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const headers: Record<string, string> = {
|
|
227
|
+
Authorization: `Bearer ${bearer}`,
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
Accept: "application/json, text/event-stream",
|
|
230
|
+
};
|
|
231
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
232
|
+
|
|
233
|
+
let response: Response;
|
|
234
|
+
try {
|
|
235
|
+
response = await fetchImpl(remoteUrl, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers,
|
|
238
|
+
body: JSON.stringify(request),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
+
return {
|
|
243
|
+
kind: "err",
|
|
244
|
+
response: errorResponse(request.id, -32603, `envoy-dispatch shim network error: ${msg}`),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (response.status === 401 && !forceRefresh) {
|
|
249
|
+
return { kind: "retry" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const respSession = response.headers.get("mcp-session-id");
|
|
253
|
+
if (respSession) sessionId = respSession;
|
|
254
|
+
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
const body = await response.text().catch(() => "");
|
|
257
|
+
return {
|
|
258
|
+
kind: "err",
|
|
259
|
+
response: errorResponse(
|
|
260
|
+
request.id,
|
|
261
|
+
-32603,
|
|
262
|
+
`envoy-dispatch shim: remote ${response.status} ${response.statusText} ${body.slice(0, 200)}`
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (request.id === undefined || request.id === null) {
|
|
268
|
+
// Notification — no response expected by JSON-RPC contract.
|
|
269
|
+
return { kind: "ok", response: null };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const body = await response.text();
|
|
273
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
274
|
+
try {
|
|
275
|
+
const parsed = ct.includes("text/event-stream") ? parseSseBody(body) : JSON.parse(body);
|
|
276
|
+
if (parsed && typeof parsed === "object") {
|
|
277
|
+
if (!forceRefresh && hasUpstreamUnauthorized(parsed)) {
|
|
278
|
+
return { kind: "retry" };
|
|
279
|
+
}
|
|
280
|
+
return { kind: "ok", response: parsed as JsonRpcResponse };
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
kind: "err",
|
|
284
|
+
response: errorResponse(
|
|
285
|
+
request.id,
|
|
286
|
+
-32603,
|
|
287
|
+
"envoy-dispatch shim: empty/invalid response body"
|
|
288
|
+
),
|
|
289
|
+
};
|
|
290
|
+
} catch (err) {
|
|
291
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
292
|
+
return {
|
|
293
|
+
kind: "err",
|
|
294
|
+
response: errorResponse(request.id, -32603, `envoy-dispatch shim: parse error: ${msg}`),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
async handle(request: JsonRpcRequest): Promise<JsonRpcResponse | null> {
|
|
301
|
+
const first = await attempt(request, false);
|
|
302
|
+
if (first.kind === "ok" || first.kind === "err")
|
|
303
|
+
return finalizeResponse(request, first.response);
|
|
304
|
+
// retry once with forced refresh on 401
|
|
305
|
+
log("envoy-dispatch shim: 401 from remote, re-minting token and retrying once");
|
|
306
|
+
const second = await attempt(request, true);
|
|
307
|
+
return finalizeResponse(request, second.kind === "retry" ? null : second.response);
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { DispatchConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenCode local MCP config shape that we inject into `config.mcp`. We use
|
|
6
|
+
* `type: "local"` (subprocess via stdio) instead of `type: "remote"` so we
|
|
7
|
+
* can rotate the GitHub bearer transparently — the StreamableHTTPClient
|
|
8
|
+
* transport snapshots static headers once at construction, which would
|
|
9
|
+
* break MCP calls after the gh-app installation token expires (~1h).
|
|
10
|
+
*
|
|
11
|
+
* The shim subprocess mints a fresh token via `gh auth token` per request
|
|
12
|
+
* (with a 50-minute in-memory cache), so OpenCode never sees an expired
|
|
13
|
+
* token. The user's `gh` shim handles per-CWD profile selection via the
|
|
14
|
+
* project's `.git/config` `[gh-app "<profile>"]` block.
|
|
15
|
+
*/
|
|
16
|
+
export interface DispatchMcpEntry {
|
|
17
|
+
type: "local";
|
|
18
|
+
command: string[];
|
|
19
|
+
environment: Record<string, string>;
|
|
20
|
+
enabled: true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BuildDispatchMcpEntryOptions {
|
|
24
|
+
dispatch: DispatchConfig | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Absolute path to the shim entry script. Defaults to the colocated
|
|
27
|
+
* `bin/dispatch-mcp-shim.ts` next to this module. Override in tests.
|
|
28
|
+
*/
|
|
29
|
+
shimPath?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Command used to launch the shim. Defaults to `bun`. Override in tests
|
|
32
|
+
* or when a different runtime is desired (e.g. `node` with a compiled
|
|
33
|
+
* shim).
|
|
34
|
+
*/
|
|
35
|
+
runtime?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_SERVER_URL = "http://localhost:8766";
|
|
39
|
+
|
|
40
|
+
function defaultShimPath(): string {
|
|
41
|
+
// import.meta.dir resolves to this file's directory in Bun, e.g.
|
|
42
|
+
// /home/ubuntu/legion/default/packages/envoy-plugin/src — go up one
|
|
43
|
+
// level to the package root, then into bin/.
|
|
44
|
+
return path.join(import.meta.dir, "..", "bin", "dispatch-mcp-shim.ts");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the OpenCode `mcp.envoy` entry. Returns null when dispatch
|
|
49
|
+
* is not enabled in envoy.json.
|
|
50
|
+
*
|
|
51
|
+
* Token availability is NOT validated here — the shim subprocess handles
|
|
52
|
+
* token fetching at request time. If `gh auth token` fails inside the
|
|
53
|
+
* shim, the affected MCP request returns a JSON-RPC error with a helpful
|
|
54
|
+
* message; other MCP servers continue to work.
|
|
55
|
+
*/
|
|
56
|
+
export function buildDispatchMcpEntry(opts: BuildDispatchMcpEntryOptions): DispatchMcpEntry | null {
|
|
57
|
+
if (!opts.dispatch?.enabled) return null;
|
|
58
|
+
|
|
59
|
+
const baseUrl = (opts.dispatch.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
|
|
60
|
+
const shimPath = opts.shimPath ?? defaultShimPath();
|
|
61
|
+
const runtime = opts.runtime ?? "bun";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
type: "local",
|
|
65
|
+
command: [runtime, shimPath],
|
|
66
|
+
environment: {
|
|
67
|
+
DISPATCH_MCP_URL: `${baseUrl}/mcp`,
|
|
68
|
+
},
|
|
69
|
+
enabled: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Inject the envoy MCP entry into an OpenCode `cfg` object. Returns a
|
|
75
|
+
* structured result instead of logging directly so the behavior is pure and
|
|
76
|
+
* testable; the caller forwards `warning` to the plugin logger when present.
|
|
77
|
+
*
|
|
78
|
+
* Idempotent on the plugin's own re-writes: when the existing `cfg.mcp.envoy`
|
|
79
|
+
* deep-equals the entry we'd inject, this is a silent no-op. OpenCode's
|
|
80
|
+
* InstanceState invalidation can re-run the plugin's config hook against a
|
|
81
|
+
* Config-service cfg that still carries our prior mutation; without the
|
|
82
|
+
* idempotency check that legitimate re-entry path produces a TUI stderr
|
|
83
|
+
* alarm. A warning still fires when the existing entry is genuinely
|
|
84
|
+
* different from ours (a user override the plugin must not clobber).
|
|
85
|
+
*/
|
|
86
|
+
export function injectEnvoyMcp(
|
|
87
|
+
cfg: { mcp?: Record<string, unknown> } & Record<string, unknown>,
|
|
88
|
+
entry: DispatchMcpEntry
|
|
89
|
+
): { warning?: string } {
|
|
90
|
+
cfg.mcp = cfg.mcp ?? {};
|
|
91
|
+
const existing = (cfg.mcp as Record<string, unknown>).envoy;
|
|
92
|
+
if (existing !== undefined) {
|
|
93
|
+
if (JSON.stringify(existing) === JSON.stringify(entry)) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
warning: "[envoy-plugin] envoy MCP entry already present in config; not overriding",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
(cfg.mcp as Record<string, unknown>).envoy = entry;
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Auto-subscription wiring for the envoy_dispatch MCP tool (Dispatch AC#4).
|
|
2
|
+
//
|
|
3
|
+
// When an agent opens a Dispatch thread via the envoy_dispatch MCP tool, the
|
|
4
|
+
// human answers by commenting on the resulting GitHub sub-issue. For the agent
|
|
5
|
+
// to RECEIVE that answer, its session must be subscribed to the thread's Envoy
|
|
6
|
+
// topic (notifications.github.<owner>.<repo>.issue.<thread>.>). The dispatch
|
|
7
|
+
// tool is served by the Go dispatch server and has no OpenCode session context,
|
|
8
|
+
// so we close the loop in the plugin (which does know the session id) from the
|
|
9
|
+
// tool.execute.after hook.
|
|
10
|
+
//
|
|
11
|
+
// This module is the pure, testable core: it turns a completed tool execution
|
|
12
|
+
// into the topic the calling session should subscribe to, or null when the
|
|
13
|
+
// execution isn't a successful envoy_dispatch call.
|
|
14
|
+
|
|
15
|
+
// Matches a GitHub issue URL anywhere in the tool output and captures
|
|
16
|
+
// owner / repo / number. The dispatch tool returns {"thread":N,"url":"…"} as
|
|
17
|
+
// its text content, so the URL is always present on success.
|
|
18
|
+
const ISSUE_URL_RE = /https?:\/\/github\.com\/([^/\s"]+)\/([^/\s"]+)\/issues\/(\d+)/i;
|
|
19
|
+
|
|
20
|
+
// The tool is registered on the Go MCP server as "dispatch" and exposed to
|
|
21
|
+
// OpenCode under the "envoy" MCP server (so commonly "envoy_dispatch"). Accept
|
|
22
|
+
// any separator a client might use while still excluding unrelated tools.
|
|
23
|
+
const DISPATCH_TOOL_RE = /(^|[-._])dispatch$/i;
|
|
24
|
+
|
|
25
|
+
export function isDispatchTool(tool: string): boolean {
|
|
26
|
+
return DISPATCH_TOOL_RE.test(tool);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the Envoy topic carrying every event on a dispatch thread issue. */
|
|
30
|
+
export function dispatchThreadTopic(owner: string, repo: string, thread: number): string {
|
|
31
|
+
return `notifications.github.${owner}.${repo}.issue.${thread}.>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Given a completed tool execution (name + textual output), return the Envoy
|
|
36
|
+
* topic the calling session should subscribe to so it receives replies on the
|
|
37
|
+
* dispatch thread — or null when this isn't a successful envoy_dispatch call.
|
|
38
|
+
*
|
|
39
|
+
* Parsing the GitHub issue URL out of the output (rather than trusting a JSON
|
|
40
|
+
* field) keeps this robust to however OpenCode surfaces the MCP result: owner,
|
|
41
|
+
* repo, and thread number all come from the canonical issue URL.
|
|
42
|
+
*/
|
|
43
|
+
export function dispatchSubscriptionTopic(tool: string, output: string): string | null {
|
|
44
|
+
if (!isDispatchTool(tool)) return null;
|
|
45
|
+
const match = ISSUE_URL_RE.exec(output);
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
const owner = match[1] as string;
|
|
48
|
+
const repo = match[2] as string;
|
|
49
|
+
const thread = Number(match[3]);
|
|
50
|
+
if (!Number.isInteger(thread) || thread <= 0) return null;
|
|
51
|
+
return dispatchThreadTopic(owner, repo, thread);
|
|
52
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File-based logger for the envoy-plugin.
|
|
7
|
+
*
|
|
8
|
+
* Plugins load in-process with OpenCode, which means any byte a plugin writes
|
|
9
|
+
* to process.stderr / process.stdout goes straight to the terminal that the
|
|
10
|
+
* TUI is rendering into — corrupting the screen. console.warn / console.error
|
|
11
|
+
* therefore become a UX bug, not a diagnostic tool.
|
|
12
|
+
*
|
|
13
|
+
* This logger writes to a file instead. Default location colocates with
|
|
14
|
+
* OpenCode's own logs (~/.local/share/opencode/log/) so plugin diagnostics
|
|
15
|
+
* are discoverable next to the host's logs without leaking into the render.
|
|
16
|
+
*
|
|
17
|
+
* The API is intentionally narrow (warn / error / info) so callers can't
|
|
18
|
+
* accidentally substitute it for console (e.g. console.log is also unsafe in
|
|
19
|
+
* a TUI host).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface LoggerOptions {
|
|
23
|
+
/** Directory the log file is written into. Created on demand. */
|
|
24
|
+
logDir?: string;
|
|
25
|
+
/** File name within logDir. Defaults to "envoy-plugin.log". */
|
|
26
|
+
fileName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Logger {
|
|
30
|
+
warn(message: string): void;
|
|
31
|
+
error(message: string): void;
|
|
32
|
+
info(message: string): void;
|
|
33
|
+
/** Resolve after every queued write has flushed to disk. Useful in tests. */
|
|
34
|
+
flush(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultLogDir(): string {
|
|
38
|
+
// Match OpenCode's log directory so plugin diagnostics live next to the
|
|
39
|
+
// host's structured logs. Falls back to tmpdir if HOME is somehow unset.
|
|
40
|
+
const home = os.homedir();
|
|
41
|
+
if (!home) return path.join(os.tmpdir(), "opencode-log");
|
|
42
|
+
return path.join(home, ".local", "share", "opencode", "log");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function format(level: "WARN" | "ERROR" | "INFO", message: string): string {
|
|
46
|
+
return `${new Date().toISOString()} ${level} ${message}\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
50
|
+
const dir = options.logDir ?? defaultLogDir();
|
|
51
|
+
const file = path.join(dir, options.fileName ?? "envoy-plugin.log");
|
|
52
|
+
|
|
53
|
+
// Serialize writes through a promise chain so concurrent log calls don't
|
|
54
|
+
// interleave bytes mid-line and so flush() can await everything.
|
|
55
|
+
let chain: Promise<void> = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
const append = (line: string) => {
|
|
58
|
+
chain = chain
|
|
59
|
+
.then(() => mkdir(dir, { recursive: true }))
|
|
60
|
+
.then(() => appendFile(file, line))
|
|
61
|
+
// Diagnostics must never crash the host. Swallow the error — there's
|
|
62
|
+
// nowhere safe to surface it (console is the very thing we're avoiding).
|
|
63
|
+
.catch(() => {});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
warn(message) {
|
|
68
|
+
append(format("WARN", message));
|
|
69
|
+
},
|
|
70
|
+
error(message) {
|
|
71
|
+
append(format("ERROR", message));
|
|
72
|
+
},
|
|
73
|
+
info(message) {
|
|
74
|
+
append(format("INFO", message));
|
|
75
|
+
},
|
|
76
|
+
async flush() {
|
|
77
|
+
await chain;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default singleton used by the plugin internals. Tests that need an
|
|
84
|
+
* isolated log path call `createLogger({ logDir })` directly.
|
|
85
|
+
*/
|
|
86
|
+
export const logger: Logger = createLogger();
|