@sjawhar/opencode-legion-envoy 0.3.2 → 0.4.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.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/server.ts",
6
6
  "exports": {
@@ -22,6 +22,8 @@
22
22
  "lint": "bunx biome check src/"
23
23
  },
24
24
  "dependencies": {
25
+ "@legion/contracts": "workspace:*",
26
+ "@legion/envoy-client": "workspace:*",
25
27
  "@opencode-ai/plugin": "~1.14.46"
26
28
  },
27
29
  "peerDependencies": {
package/src/server.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { agentSubject } from "@legion/contracts";
2
+ import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
3
+ import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
4
+ import { createEnvoyClient } from "@legion/envoy-client/transport";
1
5
  import { tool } from "@opencode-ai/plugin/tool";
2
6
  import { loadEnvoyConfig } from "./config";
3
7
  import { buildDispatchMcpEntry, injectEnvoyMcp } from "./dispatch-mcp";
@@ -5,24 +9,22 @@ import { dispatchSubscriptionTopic } from "./dispatch-subscribe";
5
9
  import { logger } from "./log";
6
10
  import { resolvePort } from "./port";
7
11
 
8
- const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
9
-
10
- /** HTTP timeout for Envoy calls — prevent hanging when NATS/Envoy is unavailable. */
11
- const CALL_TIMEOUT_MS = 5_000;
12
-
13
- async function call(path: string, init?: RequestInit) {
14
- const res = await fetch(`${root}${path}`, {
15
- ...init,
16
- signal: init?.signal ?? AbortSignal.timeout(CALL_TIMEOUT_MS),
17
- });
18
- const text = await res.text();
19
- if (!res.ok) throw new Error(text || `${res.status}`);
20
- return text;
21
- }
12
+ const [
13
+ subscribeSpec,
14
+ unsubscribeSpec,
15
+ listSpec,
16
+ sendSpec,
17
+ publishSpec,
18
+ roleSetSpec,
19
+ whoamiSpec,
20
+ sessionsSpec,
21
+ ] = envoyToolSpecs;
22
22
 
23
23
  export default async (input: { serverUrl: URL }) => {
24
24
  const cwd = process.cwd();
25
25
  const config = await loadEnvoyConfig(cwd);
26
+ const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
27
+ const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
26
28
  let activeSessionID: string | null = null;
27
29
  let activeSessionTitle: string | null = null;
28
30
  // All sessions that have become busy in this serve instance. The heartbeat
@@ -61,7 +63,7 @@ export default async (input: { serverUrl: URL }) => {
61
63
  const fetchTitle = async (sessionID: string): Promise<string | null> => {
62
64
  try {
63
65
  const res = await fetch(`${input.serverUrl.href}session/${sessionID}`, {
64
- signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
66
+ signal: AbortSignal.timeout(5_000),
65
67
  });
66
68
  if (!res.ok) return null;
67
69
  const data = (await res.json()) as { title?: string };
@@ -92,18 +94,16 @@ export default async (input: { serverUrl: URL }) => {
92
94
  port: number,
93
95
  driving: boolean
94
96
  ) =>
95
- call("/v1/interests/subscribe", {
96
- method: "POST",
97
- headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify({
99
- session_id: sessionID,
100
- dir: cwd,
101
- topics: [`notifications.agent.${sessionID}`],
97
+ envoy
98
+ .subscribe({
99
+ sessionID,
100
+ directory: cwd,
101
+ topics: [agentSubject(sessionID)],
102
102
  port,
103
103
  title: title ?? "",
104
104
  driving,
105
- }),
106
- }).catch(() => {});
105
+ })
106
+ .catch(() => {});
107
107
 
108
108
  // A process registers ONLY the sessions it has actually run (see the busy
109
109
  // handler below). It must never claim a route for a session it merely has
@@ -119,11 +119,7 @@ export default async (input: { serverUrl: URL }) => {
119
119
  // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
120
120
  // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
121
121
  // just the most recently active one. Interval is env-tunable for tests/tuning.
122
- const rawHeartbeatMs = Number(process.env.ENVOY_HEARTBEAT_MS);
123
- const heartbeatMs =
124
- Number.isFinite(rawHeartbeatMs) && rawHeartbeatMs > 0
125
- ? Math.max(rawHeartbeatMs, 25)
126
- : 2 * 60 * 1000;
122
+ const heartbeatMs = envoyDefaults.heartbeatMs;
127
123
  const heartbeatInterval = setInterval(() => {
128
124
  const port = currentPort();
129
125
  if (!port) return;
@@ -214,11 +210,7 @@ export default async (input: { serverUrl: URL }) => {
214
210
  activeSessionTitle = null;
215
211
  }
216
212
  // Best-effort: drop the deleted session's interests so routing stops.
217
- call("/v1/interests/unsubscribe", {
218
- method: "POST",
219
- headers: { "Content-Type": "application/json" },
220
- body: JSON.stringify({ session_id: deletedID, topics: [] }),
221
- }).catch(() => {});
213
+ envoy.unsubscribe({ sessionID: deletedID, topics: [] }).catch(() => {});
222
214
  }
223
215
  }
224
216
  },
@@ -233,18 +225,13 @@ export default async (input: { serverUrl: URL }) => {
233
225
  const topic = dispatchSubscriptionTopic(input.tool, output.output);
234
226
  if (!topic) return;
235
227
  try {
236
- await call("/v1/interests/subscribe", {
237
- method: "POST",
238
- headers: { "Content-Type": "application/json" },
239
- body: JSON.stringify({
240
- session_id: input.sessionID,
241
- dir: cwd,
242
- topics: [topic],
243
- port: currentPort() ?? 0,
244
- title: activeSessionTitle ?? "",
245
- // A tool call runs in this process, so it is the driving holder.
246
- driving: true,
247
- }),
228
+ await envoy.subscribe({
229
+ sessionID: input.sessionID,
230
+ directory: cwd,
231
+ topics: [topic],
232
+ port: currentPort() ?? 0,
233
+ title: activeSessionTitle ?? "",
234
+ driving: true,
248
235
  });
249
236
  } catch (err) {
250
237
  logger.warn(
@@ -259,132 +246,77 @@ export default async (input: { serverUrl: URL }) => {
259
246
  },
260
247
  tool: {
261
248
  envoy_subscribe: tool({
262
- description:
263
- "Subscribe this session to Envoy notification topics. GitHub topics are resource-scoped: notifications.github.<owner>.<repo>.pr.<number>, notifications.github.<owner>.<repo>.issue.<number>.comment, etc. Use NATS wildcards for broad subscriptions: notifications.github.<owner>.<repo>.pr.> (all PR events). Other topics: notifications.agent.<session_id>, notifications.slack.<team_id>.<channel_id>.message, notifications.slack.<team_id>.<channel_id>.mention. Use this when a session should RECEIVE future events.",
264
- args: {
265
- topics: tool.schema
266
- .array(tool.schema.string())
267
- .describe(
268
- "NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
269
- ),
270
- },
249
+ description: subscribeSpec.description,
250
+ args: { topics: tool.schema.array(tool.schema.string()) },
271
251
  async execute(args, ctx) {
272
252
  ctx.metadata({ title: "Envoy subscribe" });
273
- return call("/v1/interests/subscribe", {
274
- method: "POST",
275
- headers: { "Content-Type": "application/json" },
276
- body: JSON.stringify({
277
- session_id: ctx.sessionID,
278
- dir: ctx.directory,
253
+ return JSON.stringify(
254
+ await envoy.subscribe({
255
+ sessionID: ctx.sessionID,
256
+ directory: ctx.directory,
279
257
  topics: args.topics,
280
258
  port: currentPort() ?? 0,
281
259
  title: activeSessionTitle ?? "",
282
260
  driving: true,
283
- }),
284
- });
261
+ })
262
+ );
285
263
  },
286
264
  }),
287
265
  envoy_unsubscribe: tool({
288
- description:
289
- "Unsubscribe this session from Envoy topics, or remove all current subscriptions if topics are omitted.",
290
- args: {
291
- topics: tool.schema
292
- .array(tool.schema.string())
293
- .optional()
294
- .describe("Topics to remove, or omit to remove all"),
295
- },
266
+ description: unsubscribeSpec.description,
267
+ args: { topics: tool.schema.array(tool.schema.string()).optional() },
296
268
  async execute(args, ctx) {
297
269
  ctx.metadata({ title: "Envoy unsubscribe" });
298
- return call("/v1/interests/unsubscribe", {
299
- method: "POST",
300
- headers: { "Content-Type": "application/json" },
301
- body: JSON.stringify({
302
- session_id: ctx.sessionID,
303
- topics: args.topics ?? [],
304
- }),
305
- });
270
+ await envoy.unsubscribe({ sessionID: ctx.sessionID, topics: args.topics ?? [] });
271
+ return "ok";
306
272
  },
307
273
  }),
308
274
  envoy_list: tool({
309
- description:
310
- "List the current Envoy topic subscriptions for this session so you can confirm the exact topic shapes that are active.",
275
+ description: listSpec.description,
311
276
  args: {},
312
277
  async execute(_args, ctx) {
313
278
  ctx.metadata({ title: "Envoy list" });
314
- return call(`/v1/interests/${ctx.sessionID}`);
279
+ return JSON.stringify(await envoy.getInterest(ctx.sessionID));
315
280
  },
316
281
  }),
317
282
  envoy_send: tool({
318
- description:
319
- "Send an Envoy agent-to-agent message directly to another session by session ID. Use this for coordination between agents or to notify a known controller/worker session. This is for SEND, not subscription.",
320
- args: {
321
- target_session: tool.schema
322
- .string()
323
- .describe("Target OpenCode session ID, e.g. ses_2e6ca3034ffejVikSZ8mDwk0mR"),
324
- message: tool.schema
325
- .string()
326
- .describe("Message body to deliver to that session as a new user turn/notification"),
327
- },
283
+ description: sendSpec.description,
284
+ args: { target_session: tool.schema.string(), message: tool.schema.string() },
328
285
  async execute(args, ctx) {
329
286
  ctx.metadata({ title: "Envoy send" });
330
- return call("/v1/messages/send", {
331
- method: "POST",
332
- headers: { "Content-Type": "application/json" },
333
- body: JSON.stringify({
334
- source_session: ctx.sessionID,
335
- target_session: args.target_session,
287
+ return JSON.stringify(
288
+ await envoy.send({
289
+ sourceSessionID: ctx.sessionID,
290
+ targetSessionID: args.target_session,
336
291
  message: args.message,
337
- }),
338
- });
292
+ })
293
+ );
339
294
  },
340
295
  }),
341
296
  envoy_publish: tool({
342
- description:
343
- "Publish an Envoy message to any topic. Use for broadcast to named topics like notifications.role.legion-controller, team channels, or custom routing. Subscribers matching the topic will receive the message. This is for BROADCAST, not session-targeted delivery (use envoy_send for that).",
344
- args: {
345
- topic: tool.schema
346
- .string()
347
- .describe("NATS-style topic to publish to, e.g. notifications.role.legion-controller"),
348
- message: tool.schema.string().describe("Message body to broadcast"),
349
- },
297
+ description: publishSpec.description,
298
+ args: { topic: tool.schema.string(), message: tool.schema.string() },
350
299
  async execute(args, ctx) {
351
300
  ctx.metadata({ title: "Envoy publish" });
352
- return call("/v1/messages/publish", {
353
- method: "POST",
354
- headers: { "Content-Type": "application/json" },
355
- body: JSON.stringify({
356
- source_session: ctx.sessionID,
301
+ return JSON.stringify(
302
+ await envoy.publish({
303
+ sourceSessionID: ctx.sessionID,
357
304
  topic: args.topic,
358
305
  message: args.message,
359
- }),
360
- });
306
+ })
307
+ );
361
308
  },
362
309
  }),
363
310
  envoy_role_set: tool({
364
- description:
365
- "Set the current session as the holder of a named role. Messages published to notifications.role.<role> will route to this session. Only one session holds a role at a time — claiming it removes it from the previous holder.",
366
- args: {
367
- role: tool.schema
368
- .string()
369
- .describe(
370
- "Role name to claim (lowercase alphanumeric, hyphens, underscores). E.g. opencode-dev, legion-controller, legion-po"
371
- ),
372
- },
311
+ description: roleSetSpec.description,
312
+ args: { role: tool.schema.string() },
373
313
  async execute(args, ctx) {
374
314
  ctx.metadata({ title: "Set Envoy role" });
375
- return call("/v1/roles/set", {
376
- method: "POST",
377
- headers: { "Content-Type": "application/json" },
378
- body: JSON.stringify({
379
- session_id: ctx.sessionID,
380
- role: args.role,
381
- }),
382
- });
315
+ return JSON.stringify(await envoy.setRole({ sessionID: ctx.sessionID, role: args.role }));
383
316
  },
384
317
  }),
385
318
  envoy_whoami: tool({
386
- description:
387
- "Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
319
+ description: whoamiSpec.description,
388
320
  args: {},
389
321
  async execute(_args, ctx) {
390
322
  ctx.metadata({ title: "Envoy whoami" });
@@ -403,25 +335,15 @@ export default async (input: { serverUrl: URL }) => {
403
335
  },
404
336
  }),
405
337
  envoy_sessions: tool({
406
- description:
407
- "List all live sessions registered with Envoy. Returns session ID, machine ID, port, directory, title, topics, and last-seen timestamp for each. Use the optional machine filter to show only sessions on a specific host.",
408
- args: {
409
- machine: tool.schema
410
- .string()
411
- .optional()
412
- .describe(
413
- "Filter to sessions on this machine ID (e.g. hostname). Omit to list all machines."
414
- ),
415
- },
338
+ description: sessionsSpec.description,
339
+ args: { machine: tool.schema.string().optional() },
416
340
  async execute(args, ctx) {
417
341
  ctx.metadata({ title: "Envoy sessions" });
418
- const res = await call("/v1/sessions");
419
- if (!args.machine) return res;
420
- const sessions = JSON.parse(res) as Array<{
421
- machine_id: string;
422
- }>;
342
+ const sessions = await envoy.listSessions();
423
343
  return JSON.stringify(
424
- sessions.filter((s) => s.machine_id === args.machine),
344
+ args.machine
345
+ ? sessions.filter((session) => session.machine_id === args.machine)
346
+ : sessions,
425
347
  null,
426
348
  2
427
349
  );
package/tsconfig.json CHANGED
@@ -3,6 +3,13 @@
3
3
  "target": "ES2022",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "Bundler",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@legion/contracts": ["../contracts/src/index.ts"],
9
+ "@legion/envoy-client/defaults": ["../envoy-client/src/defaults.ts"],
10
+ "@legion/envoy-client/tool-contract": ["../envoy-client/src/tool-contract.ts"],
11
+ "@legion/envoy-client/transport": ["../envoy-client/src/transport.ts"]
12
+ },
6
13
  "strict": true,
7
14
  "noEmit": true,
8
15
  "skipLibCheck": true,