@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.
@@ -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: the
28
- daemon's boot response already gives you your own token, and the `roleToken` helper in
29
- `@legion/contracts` computes any other one exactly the way the daemon does reuse a token
30
- you've already been given before recomputing it.
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 create the pull request with the `github` tool's `pr_create` operation. The credential
169
- helper and `legion gh` provide the GitHub identity; never export, fetch, or replace a
170
- token. Other phases advance the existing branch rather than creating a replacement
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 { envoyToolSpecs, type ToolSpec } from "@legion/envoy-client/tool-contract";
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
- // One loader for the shared envoy.json contract. An invalid file refuses to
80
- // load rather than run with dispatch silently off.
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.error !== null) throw new Error(`[envoy-plugin] ${dispatchConfig.error}`);
83
- const dispatchServiceUrl = dispatchConfig.url;
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
- // The dispatch tool is present iff envoy.json (or DISPATCH_MCP_URL) names a
199
- // service. The plugin adds only what it alone knows that this is OpenCode,
200
- // and which session is asking.
201
- const dispatchTool =
202
- dispatchServiceUrl === null
203
- ? {}
204
- : {
205
- [DISPATCH_TOOL_NAME]: tool({
206
- description: DISPATCH_TOOL_DESCRIPTION,
207
- args: dispatchArgs,
208
- async execute(args, ctx) {
209
- ctx.metadata({ title: "Dispatch" });
210
- // Validate before the title lookup: an invalid call costs no request.
211
- const call = parseDispatchCall(args);
212
- const result = await executeDispatch({
213
- call,
214
- cwd: ctx.directory,
215
- host: "opencode",
216
- sessionId: ctx.sessionID,
217
- sessionTitle: (await fetchTitle(ctx.sessionID)) ?? undefined,
218
- serviceUrl: dispatchServiceUrl,
219
- });
220
- return JSON.stringify(result);
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
- // When this session opens or continues a Dispatch thread via the native
304
- // dispatch tool, auto-subscribe it to the thread's GitHub topic so the
305
- // human's reply is delivered back through Envoy. Best-effort a subscribe
306
- // failure must never surface to the model or fail the tool call.
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
- ...dispatchTool,
301
+ ...dispatchTools,
330
302
  envoy_subscribe: tool({
331
303
  description: subscribeSpec.description,
332
- args: { topics: tool.schema.array(tool.schema.string()) },
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: { topics: tool.schema.array(tool.schema.string()).optional() },
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({ sessionID: ctx.sessionID, topics: args.topics ?? [] });
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: { session_id: tool.schema.string(), message: tool.schema.string() },
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: { topic: tool.schema.string(), message: tool.schema.string() },
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: { role: tool.schema.string() },
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(await envoy.setRole({ sessionID: ctx.sessionID, role: args.role }));
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: { machine: tool.schema.string().optional() },
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
- args.machine
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
  );