@sjawhar/opencode-legion-envoy 0.16.0 → 0.18.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 +16 -9
- package/dist/src/server.js +870 -332
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +189 -220
- package/skills/legion-architect/SKILL.md +61 -38
- package/skills/legion-retro/SKILL.md +1 -1
- package/skills/legion-worker/SKILL.md +9 -8
- package/src/server.ts +74 -93
|
@@ -24,10 +24,12 @@ Your role token is not the issue key spelled out literally. The daemon encodes i
|
|
|
24
24
|
`legion-<project>-<encoded-owner>__<encoded-repo>-<number>-<role>`, escaping `_`, `.`, and
|
|
25
25
|
`-` within the owner and repo names (`_u`, `_d`, `_h`) so `__` is always the one safe
|
|
26
26
|
separator. For example, project `acme`, issue `sjawhar/legion#41`, role `architect` encodes
|
|
27
|
-
to `legion-acme-sjawhar__legion-41-architect`. Never hand-format one for another role:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
to `legion-acme-sjawhar__legion-41-architect`. Never hand-format one for another role: your
|
|
28
|
+
own role topic and your tree's architect's topic are stated at the end of your system
|
|
29
|
+
prompt (a "Legion addressing" line the daemon appends), a sibling role's topic is yours
|
|
30
|
+
with the trailing `-<role>` replaced, and the `roleToken` helper in `@legion/contracts`
|
|
31
|
+
computes any other one exactly the way the daemon does — prefer a topic you've already
|
|
32
|
+
been given before recomputing one.
|
|
31
33
|
|
|
32
34
|
If the handshake fails (a rejected boot token, or a bootstrap failure after your role
|
|
33
35
|
registered), the extension logs it and exits the process outright — it does not retry, and
|
|
@@ -165,10 +167,9 @@ The provisioned issue workspace configures `credential.helper` with the daemon's
|
|
|
165
167
|
credential command, so `jj -R "$LEGION_WORKSPACE" git push` authenticates transparently
|
|
166
168
|
through the same session capability. Never handle a token.
|
|
167
169
|
|
|
168
|
-
Then
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
bookmark or PR.
|
|
170
|
+
Then open the pull request with `legion gh -- pr create`. The credential helper and
|
|
171
|
+
`legion gh` provide the GitHub identity; never export, fetch, or replace a token. Other
|
|
172
|
+
phases advance the existing branch rather than creating a replacement bookmark or PR.
|
|
172
173
|
|
|
173
174
|
## PR body and merge-queue discipline
|
|
174
175
|
|
package/src/server.ts
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { agentSubject } from "@legion/contracts";
|
|
4
|
+
import { agentSubject, dispatchToolSpecs, zodSchemaApi } from "@legion/contracts";
|
|
5
5
|
import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
|
|
6
|
-
import { executeDispatch } from "@legion/envoy-client/dispatch-call";
|
|
7
6
|
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";
|
|
7
|
+
import { executeDispatchTool } from "@legion/envoy-client/dispatch-execute";
|
|
15
8
|
import { dispatchSubscriptionTopic } from "@legion/envoy-client/dispatch-subscribe";
|
|
16
9
|
import { machineID } from "@legion/envoy-client/machine";
|
|
17
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
envoyToolSpecs,
|
|
12
|
+
type ToolSpec,
|
|
13
|
+
toMessageMetadata,
|
|
14
|
+
} from "@legion/envoy-client/tool-contract";
|
|
18
15
|
import { createEnvoyClient } from "@legion/envoy-client/transport";
|
|
19
|
-
import { tool } from "@opencode-ai/plugin/tool";
|
|
16
|
+
import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool";
|
|
20
17
|
import { logger } from "./log";
|
|
21
18
|
import { resolvePort } from "./port";
|
|
22
19
|
|
|
@@ -47,40 +44,16 @@ const roleSetSpec = toolSpec("envoy_role_set");
|
|
|
47
44
|
const whoamiSpec = toolSpec("envoy_whoami");
|
|
48
45
|
const sessionsSpec = toolSpec("envoy_sessions");
|
|
49
46
|
|
|
50
|
-
// The dispatch tool's LLM-facing schema, built with OpenCode's own zod
|
|
51
|
-
// (`tool.schema`): OpenCode converts plugin schemas with that instance, and a
|
|
52
|
-
// schema from another zod build loses its checks and descriptions on the way.
|
|
53
|
-
// It mirrors `dispatchToolShape` from the contract field for field — the test
|
|
54
|
-
// suite compares the two as JSON Schema so they cannot drift apart.
|
|
55
|
-
const dispatchQuestionOption = tool.schema.strictObject({
|
|
56
|
-
label: tool.schema.string().min(1),
|
|
57
|
-
description: tool.schema.string().optional(),
|
|
58
|
-
});
|
|
59
|
-
const dispatchQuestion = tool.schema.strictObject({
|
|
60
|
-
question: tool.schema.string().min(1),
|
|
61
|
-
header: tool.schema.string().optional(),
|
|
62
|
-
options: tool.schema.array(dispatchQuestionOption).optional(),
|
|
63
|
-
multiple: tool.schema.boolean().optional(),
|
|
64
|
-
custom: tool.schema.boolean().optional(),
|
|
65
|
-
});
|
|
66
|
-
const dispatchArgs = {
|
|
67
|
-
subject: tool.schema.string().describe(DISPATCH_ARGUMENTS.subject).optional(),
|
|
68
|
-
thread: tool.schema.string().describe(DISPATCH_ARGUMENTS.thread).optional(),
|
|
69
|
-
context: tool.schema.string().describe(DISPATCH_ARGUMENTS.context),
|
|
70
|
-
question: tool.schema.string().describe(DISPATCH_ARGUMENTS.question),
|
|
71
|
-
ask: tool.schema.array(dispatchQuestion).describe(DISPATCH_ARGUMENTS.ask).optional(),
|
|
72
|
-
urgency: tool.schema.enum(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
|
|
73
|
-
repo: tool.schema.string().describe(DISPATCH_ARGUMENTS.repo).optional(),
|
|
74
|
-
parent: tool.schema.string().describe(DISPATCH_ARGUMENTS.parent).optional(),
|
|
75
|
-
};
|
|
76
|
-
|
|
77
47
|
export default async (input: { serverUrl: URL }) => {
|
|
78
48
|
const cwd = process.cwd();
|
|
79
|
-
//
|
|
80
|
-
//
|
|
49
|
+
// Dispatch configuration never prevents Envoy from loading. Disabled native
|
|
50
|
+
// tools are visible in the host log, including normal unconfigured installs.
|
|
81
51
|
const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
|
|
82
|
-
if (dispatchConfig.
|
|
83
|
-
|
|
52
|
+
if (!dispatchConfig.enabled) {
|
|
53
|
+
logger.warn(
|
|
54
|
+
`envoy: dispatch tools disabled — ${dispatchConfig.error ?? "no Dispatch URL configured"}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
84
57
|
const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
|
|
85
58
|
const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
|
|
86
59
|
let activeSessionID: string | null = null;
|
|
@@ -195,32 +168,32 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
195
168
|
clearInterval(heartbeatInterval);
|
|
196
169
|
});
|
|
197
170
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
171
|
+
// Native Dispatch tools are present only when the shared configuration has
|
|
172
|
+
// both a server URL and bearer token. Each tool returns its details as
|
|
173
|
+
// OpenCode metadata so the post-execution hook can subscribe to its topic.
|
|
174
|
+
const dispatchTools: Record<string, ToolDefinition> = {};
|
|
175
|
+
if (dispatchConfig.enabled) {
|
|
176
|
+
for (const spec of dispatchToolSpecs) {
|
|
177
|
+
dispatchTools[spec.name] = tool({
|
|
178
|
+
description: spec.description,
|
|
179
|
+
args: spec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
180
|
+
async execute(args, ctx) {
|
|
181
|
+
ctx.metadata({ title: "Dispatch" });
|
|
182
|
+
const result = await executeDispatchTool({
|
|
183
|
+
tool: spec.name,
|
|
184
|
+
args: args as Record<string, unknown>,
|
|
185
|
+
cwd: ctx.directory,
|
|
186
|
+
host: "opencode",
|
|
187
|
+
sessionId: ctx.sessionID,
|
|
188
|
+
sessionTitle: (await fetchTitle(ctx.sessionID)) ?? undefined,
|
|
189
|
+
config: dispatchConfig,
|
|
190
|
+
env: process.env,
|
|
191
|
+
});
|
|
192
|
+
return { title: "Dispatch", output: result.text, metadata: result.details };
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
224
197
|
|
|
225
198
|
return {
|
|
226
199
|
config: (cfg: { skills?: { paths?: string[] } } & Record<string, unknown>) => {
|
|
@@ -300,11 +273,10 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
300
273
|
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
301
274
|
output: { title: string; output: string; metadata: unknown }
|
|
302
275
|
) => {
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
const topic = dispatchSubscriptionTopic(input.tool, output.output);
|
|
276
|
+
// Native Dispatch tools return `DispatchToolResult.details` in OpenCode
|
|
277
|
+
// output metadata, so only mutations with a Dispatch topic are followed.
|
|
278
|
+
// Best-effort — a subscribe failure must never surface to the model.
|
|
279
|
+
const topic = dispatchSubscriptionTopic(output.metadata);
|
|
308
280
|
if (!topic) return;
|
|
309
281
|
try {
|
|
310
282
|
await envoy.subscribe({
|
|
@@ -326,17 +298,17 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
326
298
|
clearInterval(heartbeatInterval);
|
|
327
299
|
},
|
|
328
300
|
tool: {
|
|
329
|
-
...
|
|
301
|
+
...dispatchTools,
|
|
330
302
|
envoy_subscribe: tool({
|
|
331
303
|
description: subscribeSpec.description,
|
|
332
|
-
args:
|
|
304
|
+
args: subscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
333
305
|
async execute(args, ctx) {
|
|
334
306
|
ctx.metadata({ title: "Envoy subscribe" });
|
|
335
307
|
return JSON.stringify(
|
|
336
308
|
await envoy.subscribe({
|
|
337
309
|
sessionID: ctx.sessionID,
|
|
338
310
|
directory: ctx.directory,
|
|
339
|
-
topics: args.topics,
|
|
311
|
+
topics: args.topics as string[],
|
|
340
312
|
port: currentPort() ?? 0,
|
|
341
313
|
title: activeSessionTitle ?? "",
|
|
342
314
|
driving: true,
|
|
@@ -346,16 +318,19 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
346
318
|
}),
|
|
347
319
|
envoy_unsubscribe: tool({
|
|
348
320
|
description: unsubscribeSpec.description,
|
|
349
|
-
args:
|
|
321
|
+
args: unsubscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
350
322
|
async execute(args, ctx) {
|
|
351
323
|
ctx.metadata({ title: "Envoy unsubscribe" });
|
|
352
|
-
await envoy.unsubscribe({
|
|
324
|
+
await envoy.unsubscribe({
|
|
325
|
+
sessionID: ctx.sessionID,
|
|
326
|
+
topics: (args.topics as string[] | undefined) ?? [],
|
|
327
|
+
});
|
|
353
328
|
return "ok";
|
|
354
329
|
},
|
|
355
330
|
}),
|
|
356
331
|
envoy_list: tool({
|
|
357
332
|
description: listSpec.description,
|
|
358
|
-
args:
|
|
333
|
+
args: listSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
359
334
|
async execute(_args, ctx) {
|
|
360
335
|
ctx.metadata({ title: "Envoy list" });
|
|
361
336
|
return JSON.stringify(await envoy.getInterest(ctx.sessionID));
|
|
@@ -363,43 +338,47 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
363
338
|
}),
|
|
364
339
|
envoy_send: tool({
|
|
365
340
|
description: sendSpec.description,
|
|
366
|
-
args:
|
|
341
|
+
args: sendSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
367
342
|
async execute(args, ctx) {
|
|
368
343
|
ctx.metadata({ title: "Envoy send" });
|
|
369
344
|
return JSON.stringify(
|
|
370
345
|
await envoy.send({
|
|
371
346
|
sourceSessionID: ctx.sessionID,
|
|
372
|
-
targetSessionID: args.session_id,
|
|
373
|
-
message: args.message,
|
|
347
|
+
targetSessionID: args.session_id as string,
|
|
348
|
+
message: args.message as string,
|
|
349
|
+
...toMessageMetadata(args as never),
|
|
374
350
|
})
|
|
375
351
|
);
|
|
376
352
|
},
|
|
377
353
|
}),
|
|
378
354
|
envoy_publish: tool({
|
|
379
355
|
description: publishSpec.description,
|
|
380
|
-
args:
|
|
356
|
+
args: publishSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
381
357
|
async execute(args, ctx) {
|
|
382
358
|
ctx.metadata({ title: "Envoy publish" });
|
|
383
359
|
return JSON.stringify(
|
|
384
360
|
await envoy.publish({
|
|
385
361
|
sourceSessionID: ctx.sessionID,
|
|
386
|
-
topic: args.topic,
|
|
387
|
-
message: args.message,
|
|
362
|
+
topic: args.topic as string,
|
|
363
|
+
message: args.message as string,
|
|
364
|
+
...toMessageMetadata(args as never),
|
|
388
365
|
})
|
|
389
366
|
);
|
|
390
367
|
},
|
|
391
368
|
}),
|
|
392
369
|
envoy_role_set: tool({
|
|
393
370
|
description: roleSetSpec.description,
|
|
394
|
-
args:
|
|
371
|
+
args: roleSetSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
395
372
|
async execute(args, ctx) {
|
|
396
373
|
ctx.metadata({ title: "Set Envoy role" });
|
|
397
|
-
return JSON.stringify(
|
|
374
|
+
return JSON.stringify(
|
|
375
|
+
await envoy.setRole({ sessionID: ctx.sessionID, role: args.role as string })
|
|
376
|
+
);
|
|
398
377
|
},
|
|
399
378
|
}),
|
|
400
379
|
envoy_whoami: tool({
|
|
401
380
|
description: whoamiSpec.description,
|
|
402
|
-
args:
|
|
381
|
+
args: whoamiSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
403
382
|
async execute(_args, ctx) {
|
|
404
383
|
ctx.metadata({ title: "Envoy whoami" });
|
|
405
384
|
const sessionID = ctx.sessionID;
|
|
@@ -418,14 +397,16 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
418
397
|
}),
|
|
419
398
|
envoy_sessions: tool({
|
|
420
399
|
description: sessionsSpec.description,
|
|
421
|
-
args:
|
|
400
|
+
args: sessionsSpec.arguments(zodSchemaApi(tool.schema)) as never,
|
|
422
401
|
async execute(args, ctx) {
|
|
423
402
|
ctx.metadata({ title: "Envoy sessions" });
|
|
424
|
-
const sessions = await envoy.listSessions(
|
|
403
|
+
const sessions = await envoy.listSessions({
|
|
404
|
+
...(args.dir === undefined ? {} : { directory: args.dir as string }),
|
|
405
|
+
...(args.title === undefined ? {} : { title: args.title as string }),
|
|
406
|
+
});
|
|
407
|
+
const machine = args.machine as string | undefined;
|
|
425
408
|
return JSON.stringify(
|
|
426
|
-
|
|
427
|
-
? sessions.filter((session) => session.machine_id === args.machine)
|
|
428
|
-
: sessions,
|
|
409
|
+
machine ? sessions.filter((session) => session.machine_id === machine) : sessions,
|
|
429
410
|
null,
|
|
430
411
|
2
|
|
431
412
|
);
|