@sjawhar/opencode-legion-envoy 0.1.0-alpha.0 → 0.1.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 ADDED
@@ -0,0 +1,33 @@
1
+ # Envoy Plugin Package
2
+
3
+ OpenCode plugin package for Legion's Envoy subsystem.
4
+
5
+ ## Overview
6
+
7
+ This plugin exposes the Envoy tools and maintains the live session registry metadata Envoy needs for hot delivery.
8
+
9
+ It is the user-facing bridge between OpenCode sessions and Envoy transport.
10
+
11
+ ## Where to look
12
+
13
+ | Task | Location | Notes |
14
+ | ------------------- | ---------------------- | ------------------------------------------------------------------ |
15
+ | Tool definitions | `src/index.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send` |
16
+ | Packaging metadata | `package.json` | npm identity, build scripts |
17
+ | Distribution output | `dist/index.js` | built plugin consumed by OpenCode |
18
+ | Host rollout helper | `scripts/sync-host.sh` | sync dist + shim to remote host |
19
+
20
+ ## Critical conventions
21
+
22
+ - Tool descriptions must be self-describing enough that agents can infer correct topic formats.
23
+ - Slack examples must use real `team_id` values, not workspace slugs.
24
+ - This package owns the session-registry/port-backfill behavior now; do not split that back into a second plugin casually.
25
+ - Keep the plugin source-of-truth here even if a dotfiles shim is still used for rollout convenience.
26
+
27
+ ## Topic reminders
28
+
29
+ - Agent: `notifications.agent.<session_id>`
30
+ - GitHub: `notifications.github.<owner>.<repo>.<kind>`
31
+ - Slack: `notifications.slack.<team_id>.<channel_id>.<message|mention>`
32
+
33
+ If you are unsure what a session is subscribed to, use `envoy_list()`.
package/README.md CHANGED
@@ -11,11 +11,23 @@ This package exposes:
11
11
 
12
12
  It also maintains the live session registry metadata needed for Envoy to discover OpenCode sessions and their API ports.
13
13
 
14
+ Slack topic examples must use the real Slack `team_id`, for example:
15
+
16
+ - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention`
17
+
18
+ Do not use workspace slugs like `trajectorylabs` in the topic path.
19
+
14
20
  ## Sync to another machine
15
21
 
16
22
  ```bash
17
- cd ~/legion/default/packages/envoy-plugin
18
- ./scripts/sync-host.sh sami@sami
23
+ # From the repo root:
24
+ ./packages/envoy-plugin/scripts/sync-host.sh sami@sami
25
+
26
+ # Or via the combined envoy sync:
27
+ ./scripts/sync-envoy-host.sh sami@sami
19
28
  ```
20
29
 
21
- This syncs the built plugin dist and the dotfiles shim to the target host.
30
+ The sync script downloads the latest envoy-plugin release tarball from GitHub,
31
+ extracts it to `~/legion/default/packages/envoy-plugin/` on the remote host, and
32
+ updates the remote's `opencode.json` to use a `file://` reference instead of the
33
+ npm package. Requires `gh` CLI on the machine running the script.
package/dist/index.js CHANGED
@@ -15,8 +15,7 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // src/index.ts
18
- import { execFileSync } from "child_process";
19
- import { readFileSync, writeFileSync } from "fs";
18
+ import { readFileSync, unlinkSync, writeFileSync } from "fs";
20
19
 
21
20
  // ../../node_modules/.bun/zod@4.1.8/node_modules/zod/v4/classic/external.js
22
21
  var exports_external = {};
@@ -12339,6 +12338,31 @@ function tool(input) {
12339
12338
  }
12340
12339
  tool.schema = exports_external;
12341
12340
 
12341
+ // src/port.ts
12342
+ import { execFileSync } from "child_process";
12343
+ function resolvePort(serverUrl, exec = execFileSync) {
12344
+ const urlPort = Number.parseInt(serverUrl.port, 10);
12345
+ if (Number.isFinite(urlPort) && urlPort > 0)
12346
+ return urlPort;
12347
+ try {
12348
+ const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
12349
+ for (const line of output.split(`
12350
+ `)) {
12351
+ if (!line.includes(`pid=${process.pid}`))
12352
+ continue;
12353
+ const parts = line.trim().split(/\s+/);
12354
+ const local = parts[3];
12355
+ const match = local?.match(/:(\d+)$/);
12356
+ if (!match)
12357
+ continue;
12358
+ const port = Number.parseInt(match[1], 10);
12359
+ if (Number.isFinite(port) && port > 0)
12360
+ return port;
12361
+ }
12362
+ } catch {}
12363
+ return null;
12364
+ }
12365
+
12342
12366
  // src/index.ts
12343
12367
  var root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
12344
12368
  async function call(path, init) {
@@ -12349,47 +12373,44 @@ async function call(path, init) {
12349
12373
  return text;
12350
12374
  }
12351
12375
  var src_default = async (input) => {
12352
- const shellPid = process.env.OC_SHELL_PID;
12353
12376
  const registryDir = process.env.OC_REGISTRY;
12354
- const file2 = shellPid && registryDir ? `${registryDir}/${shellPid}.json` : null;
12355
12377
  let activeSessionID = null;
12356
- const update = (patch) => {
12378
+ let _activeFile = null;
12379
+ const registryFile = (sessionID) => registryDir ? `${registryDir}/${sessionID}.json` : null;
12380
+ const update = (sessionID, patch) => {
12381
+ const file2 = registryFile(sessionID);
12357
12382
  if (!file2)
12358
12383
  return;
12359
12384
  try {
12360
- const data = JSON.parse(readFileSync(file2, "utf-8"));
12385
+ let data = {};
12386
+ try {
12387
+ data = JSON.parse(readFileSync(file2, "utf-8"));
12388
+ } catch {
12389
+ data = { pid: process.pid, dir: process.cwd(), started: new Date().toISOString() };
12390
+ }
12361
12391
  Object.assign(data, patch);
12362
12392
  writeFileSync(file2, `${JSON.stringify(data)}
12363
12393
  `);
12394
+ registryFiles.add(file2);
12364
12395
  } catch {}
12365
12396
  };
12397
+ let portWarningLogged = false;
12366
12398
  const currentPort = () => {
12367
- const value = Number.parseInt(input.serverUrl.port, 10);
12368
- if (Number.isFinite(value) && value > 0)
12369
- return value;
12370
- try {
12371
- const output = execFileSync("ss", ["-tlnp"], { encoding: "utf-8" });
12372
- for (const line of output.split(`
12373
- `)) {
12374
- if (!line.includes(`pid=${process.pid}`))
12375
- continue;
12376
- const parts = line.trim().split(/\s+/);
12377
- const local = parts[3];
12378
- const match = local?.match(/:(\d+)$/);
12379
- if (!match)
12380
- continue;
12381
- const port = Number.parseInt(match[1], 10);
12382
- if (Number.isFinite(port) && port > 0)
12383
- return port;
12384
- }
12385
- } catch {}
12386
- return null;
12399
+ const port = resolvePort(input.serverUrl);
12400
+ if (!port && !portWarningLogged) {
12401
+ portWarningLogged = true;
12402
+ console.error(`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`);
12403
+ }
12404
+ if (port)
12405
+ portWarningLogged = false;
12406
+ return port;
12387
12407
  };
12388
- const syncPort = () => {
12408
+ const syncPort = (sessionID) => {
12389
12409
  const value = currentPort();
12390
12410
  if (!value)
12391
12411
  return false;
12392
- update({ port: value });
12412
+ if (sessionID)
12413
+ update(sessionID, { port: value });
12393
12414
  return true;
12394
12415
  };
12395
12416
  const fetchSession = async (sessionID) => {
@@ -12403,29 +12424,71 @@ var src_default = async (input) => {
12403
12424
  return null;
12404
12425
  }
12405
12426
  };
12406
- if (file2) {
12427
+ const registryFiles = new Set;
12428
+ if (registryDir) {
12407
12429
  syncPort();
12408
12430
  const timer = setInterval(() => {
12409
- if (syncPort())
12431
+ if (syncPort(activeSessionID ?? undefined))
12410
12432
  clearInterval(timer);
12411
12433
  }, 1000);
12434
+ const heartbeatInterval = setInterval(() => {
12435
+ if (!activeSessionID)
12436
+ return;
12437
+ const port = currentPort();
12438
+ if (!port)
12439
+ return;
12440
+ call("/v1/interests/subscribe", {
12441
+ method: "POST",
12442
+ headers: { "Content-Type": "application/json" },
12443
+ body: JSON.stringify({
12444
+ session_id: activeSessionID,
12445
+ dir: process.cwd(),
12446
+ topics: [`notifications.agent.${activeSessionID}`],
12447
+ port
12448
+ })
12449
+ }).catch(() => {});
12450
+ }, 2 * 60 * 1000);
12451
+ process.on("exit", () => {
12452
+ clearInterval(heartbeatInterval);
12453
+ for (const f of registryFiles) {
12454
+ try {
12455
+ unlinkSync(f);
12456
+ } catch {}
12457
+ }
12458
+ });
12412
12459
  }
12413
12460
  return {
12414
- event: file2 ? async ({ event }) => {
12415
- syncPort();
12461
+ event: registryDir ? async ({ event }) => {
12462
+ if (activeSessionID)
12463
+ syncPort(activeSessionID);
12416
12464
  if (event.type === "session.status" && event.properties?.status?.type === "busy") {
12417
12465
  const sessionID = event.properties?.sessionID;
12418
12466
  if (sessionID && sessionID !== activeSessionID) {
12419
12467
  activeSessionID = sessionID;
12468
+ _activeFile = registryFile(sessionID);
12420
12469
  const session = await fetchSession(sessionID);
12421
12470
  if (session)
12422
- update({ session });
12471
+ update(sessionID, { session });
12472
+ syncPort(sessionID);
12473
+ const port = currentPort();
12474
+ if (port) {
12475
+ call("/v1/interests/subscribe", {
12476
+ method: "POST",
12477
+ headers: { "Content-Type": "application/json" },
12478
+ body: JSON.stringify({
12479
+ session_id: sessionID,
12480
+ dir: process.cwd(),
12481
+ topics: [`notifications.agent.${sessionID}`],
12482
+ port
12483
+ })
12484
+ }).catch(() => {});
12485
+ }
12423
12486
  }
12424
12487
  }
12425
12488
  if (event.type === "session.updated") {
12426
12489
  const info = event.properties?.info;
12427
12490
  if (info && info.id === activeSessionID) {
12428
- update({ session: { id: info.id, title: info.title || "" } });
12491
+ update(info.id, { session: { id: info.id, title: info.title || "" } });
12429
12492
  }
12430
12493
  }
12431
12494
  if (event.type === "session.idle") {
@@ -12433,15 +12496,15 @@ var src_default = async (input) => {
12433
12496
  if (sessionID && sessionID === activeSessionID) {
12434
12497
  const session = await fetchSession(sessionID);
12435
12498
  if (session)
12436
- update({ session });
12499
+ update(sessionID, { session });
12437
12500
  }
12438
12501
  }
12439
12502
  } : undefined,
12440
12503
  tool: {
12441
12504
  envoy_subscribe: tool({
12442
- description: "Subscribe this session to envoy notification topics",
12505
+ description: "Subscribe this session to Envoy notification topics. Use exact NATS-style topic strings such as notifications.agent.<session_id>, notifications.github.<owner>.<repo>.pr, notifications.github.<owner>.<repo>.issue, notifications.github.<owner>.<repo>.comment, notifications.github.<owner>.<repo>.ci, notifications.slack.<team_id>.<channel_id>.message, or notifications.slack.<team_id>.<channel_id>.mention. Use this when a session should RECEIVE future events.",
12443
12506
  args: {
12444
- topics: tool.schema.array(tool.schema.string()).describe("NATS-style topic patterns to subscribe to")
12507
+ topics: tool.schema.array(tool.schema.string()).describe("NATS-style topic patterns to subscribe to. Examples: notifications.agent.ses_123, notifications.github.trajectory-labs-pbc.agent-c.pr, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention")
12445
12508
  },
12446
12509
  async execute(args, ctx) {
12447
12510
  ctx.metadata({ title: "Envoy subscribe" });
@@ -12451,13 +12514,14 @@ var src_default = async (input) => {
12451
12514
  body: JSON.stringify({
12452
12515
  session_id: ctx.sessionID,
12453
12516
  dir: ctx.directory,
12454
- topics: args.topics
12517
+ topics: args.topics,
12518
+ port: currentPort() ?? 0
12455
12519
  })
12456
12520
  });
12457
12521
  }
12458
12522
  }),
12459
12523
  envoy_unsubscribe: tool({
12460
- description: "Unsubscribe this session from envoy topics, or all if omitted",
12524
+ description: "Unsubscribe this session from Envoy topics, or remove all current subscriptions if topics are omitted.",
12461
12525
  args: {
12462
12526
  topics: tool.schema.array(tool.schema.string()).optional().describe("Topics to remove, or omit to remove all")
12463
12527
  },
@@ -12474,7 +12538,7 @@ var src_default = async (input) => {
12474
12538
  }
12475
12539
  }),
12476
12540
  envoy_list: tool({
12477
- description: "List envoy subscriptions for this session",
12541
+ description: "List the current Envoy topic subscriptions for this session so you can confirm the exact topic shapes that are active.",
12478
12542
  args: {},
12479
12543
  async execute(_args, ctx) {
12480
12544
  ctx.metadata({ title: "Envoy list" });
@@ -12482,10 +12546,10 @@ var src_default = async (input) => {
12482
12546
  }
12483
12547
  }),
12484
12548
  envoy_send: tool({
12485
- description: "Send an envoy agent-to-agent message to another session",
12549
+ description: "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.",
12486
12550
  args: {
12487
- target_session: tool.schema.string().describe("Target session ID"),
12488
- message: tool.schema.string().describe("Message to deliver")
12551
+ target_session: tool.schema.string().describe("Target OpenCode session ID, e.g. ses_2e6ca3034ffejVikSZ8mDwk0mR"),
12552
+ message: tool.schema.string().describe("Message body to deliver to that session as a new user turn/notification")
12489
12553
  },
12490
12554
  async execute(args, ctx) {
12491
12555
  ctx.metadata({ title: "Envoy send" });
@@ -12499,6 +12563,25 @@ var src_default = async (input) => {
12499
12563
  })
12500
12564
  });
12501
12565
  }
12566
+ }),
12567
+ envoy_publish: tool({
12568
+ description: "Publish an Envoy message to any topic. Use for broadcast to named topics like notifications.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).",
12569
+ args: {
12570
+ topic: tool.schema.string().describe("NATS-style topic to publish to, e.g. notifications.legion.controller"),
12571
+ message: tool.schema.string().describe("Message body to broadcast")
12572
+ },
12573
+ async execute(args, ctx) {
12574
+ ctx.metadata({ title: "Envoy publish" });
12575
+ return call("/v1/messages/publish", {
12576
+ method: "POST",
12577
+ headers: { "Content-Type": "application/json" },
12578
+ body: JSON.stringify({
12579
+ source_session: ctx.sessionID,
12580
+ topic: args.topic,
12581
+ message: args.message
12582
+ })
12583
+ });
12584
+ }
12502
12585
  })
12503
12586
  }
12504
12587
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -2,7 +2,55 @@
2
2
  set -euo pipefail
3
3
 
4
4
  host="${1:?usage: sync-host.sh user@host}"
5
- root="$(cd "$(dirname "$0")/../../.." && pwd)"
6
5
 
7
- rsync -az "$root/packages/envoy-plugin/dist/" "$host:~/legion/default/packages/envoy-plugin/dist/"
8
- rsync -az "$HOME/.dotfiles/opencode/plugins/envoy.ts" "$host:~/.dotfiles/opencode/plugins/envoy.ts"
6
+ PLUGIN_DIR="legion/default/packages/envoy-plugin"
7
+ PLUGIN_REF="file://{env:HOME}/${PLUGIN_DIR}/dist/index.js"
8
+ REPO="sjawhar/legion"
9
+
10
+ # Find latest envoy release tag
11
+ tag=$(gh release list --repo "$REPO" --limit 10 --json tagName \
12
+ --jq '[.[] | select(.tagName | startswith("legion-envoy-"))][0].tagName')
13
+ if [ -z "$tag" ]; then
14
+ echo "ERROR: No legion-envoy release found" >&2
15
+ exit 1
16
+ fi
17
+ echo "Using release: $tag"
18
+
19
+ # Download plugin tarball
20
+ tmpdir=$(mktemp -d)
21
+ trap 'rm -rf "$tmpdir"' EXIT
22
+
23
+ gh release download "$tag" \
24
+ --repo "$REPO" \
25
+ --pattern '*.tgz' \
26
+ --dir "$tmpdir" \
27
+ --clobber
28
+
29
+ tgz=$(find "$tmpdir" -name '*.tgz' -print -quit)
30
+ if [ -z "$tgz" ]; then
31
+ echo "ERROR: No .tgz found in release $tag" >&2
32
+ exit 1
33
+ fi
34
+ echo "Downloaded: $(basename "$tgz")"
35
+
36
+ # Install on remote: extract tarball (strip package/ prefix from npm pack output)
37
+ ssh "$host" "rm -rf ~/${PLUGIN_DIR} && mkdir -p ~/${PLUGIN_DIR}"
38
+ scp -q "$tgz" "$host:/tmp/envoy-plugin.tgz"
39
+ ssh "$host" "tar xzf /tmp/envoy-plugin.tgz --strip-components=1 -C ~/${PLUGIN_DIR} && rm /tmp/envoy-plugin.tgz"
40
+ echo "Installed plugin to ~/${PLUGIN_DIR}"
41
+
42
+ # Update opencode.json: replace npm package reference with file:// path
43
+ # Resolves symlinks so we don't clobber dotfiles symlink structure
44
+ ssh "$host" "
45
+ CONFIG=\$(readlink -f \$HOME/.config/opencode/opencode.json 2>/dev/null || echo \$HOME/.config/opencode/opencode.json)
46
+ if [ -f \"\$CONFIG\" ]; then
47
+ jq --arg ref '$PLUGIN_REF' \\
48
+ '(.plugin // []) |= [.[] | if (test(\"opencode-legion-envoy\") and (test(\"^file://\") | not)) then \$ref else . end]' \\
49
+ \"\$CONFIG\" > /tmp/opencode.json.tmp && mv /tmp/opencode.json.tmp \"\$CONFIG\"
50
+ echo \"Updated opencode.json: \$CONFIG\"
51
+ else
52
+ echo \"WARNING: opencode.json not found at \$CONFIG — add plugin manually: $PLUGIN_REF\"
53
+ fi
54
+ "
55
+
56
+ echo "Done: $host envoy-plugin synced from release $tag"
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { resolvePort } from "../port";
3
+
4
+ describe("resolvePort", () => {
5
+ const noopExec = (() => {
6
+ throw new Error("ss not available");
7
+ }) as typeof import("node:child_process").execFileSync;
8
+
9
+ const ssOutput = (pid: number, port: number) =>
10
+ [
11
+ "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
12
+ `LISTEN 0 511 127.0.0.1:${port} 0.0.0.0:* users:(("bun",pid=${pid},fd=6))`,
13
+ "",
14
+ ].join("\n");
15
+
16
+ describe("URL port extraction", () => {
17
+ it("returns port from standard non-default URL", () => {
18
+ expect(resolvePort(new URL("http://127.0.0.1:4096"), noopExec)).toBe(4096);
19
+ });
20
+
21
+ it("returns port for high serve ports", () => {
22
+ expect(resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
23
+ });
24
+
25
+ it("returns port for localhost URLs", () => {
26
+ expect(resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
27
+ });
28
+
29
+ it("returns port for IPv6 URLs", () => {
30
+ expect(resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
31
+ });
32
+ });
33
+
34
+ describe("ss fallback", () => {
35
+ it("uses ss when URL has no port (default HTTP)", () => {
36
+ const exec = mock(() => ssOutput(process.pid, 4096));
37
+ expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
38
+ expect(exec).toHaveBeenCalledWith("ss", ["-tlnp"], { encoding: "utf-8" });
39
+ });
40
+
41
+ it("uses ss when URL port is 0", () => {
42
+ // URL("http://127.0.0.1:0").port is "0", which is not > 0
43
+ const exec = mock(() => ssOutput(process.pid, 13381));
44
+ expect(resolvePort(new URL("http://127.0.0.1:0"), exec as never)).toBe(13381);
45
+ });
46
+
47
+ it("ignores ss lines with different PIDs", () => {
48
+ const exec = mock(() => ssOutput(99999, 4096));
49
+ expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
50
+ });
51
+
52
+ it("handles multiple ss entries and picks the matching PID", () => {
53
+ const output = [
54
+ "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
55
+ `LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("node",pid=99999,fd=6))`,
56
+ `LISTEN 0 511 127.0.0.1:4096 0.0.0.0:* users:(("bun",pid=${process.pid},fd=7))`,
57
+ "",
58
+ ].join("\n");
59
+ const exec = mock(() => output);
60
+ expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
61
+ });
62
+
63
+ it("handles IPv6 listening addresses in ss output", () => {
64
+ const output = [
65
+ "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
66
+ `LISTEN 0 511 [::]:4096 [::]:* users:(("bun",pid=${process.pid},fd=6))`,
67
+ "",
68
+ ].join("\n");
69
+ const exec = mock(() => output);
70
+ expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
71
+ });
72
+ });
73
+ describe("failure cases", () => {
74
+ it("returns null when ss is not available", () => {
75
+ expect(resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
76
+ });
77
+
78
+ it("returns null when ss returns empty output", () => {
79
+ const exec = mock(() => "");
80
+ expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("edge cases", () => {
85
+ it("URL.port is empty string for default HTTP port 80", () => {
86
+ // http://127.0.0.1:80 → URL.port is "" (80 is default for http)
87
+ const url = new URL("http://127.0.0.1:80");
88
+ expect(url.port).toBe("");
89
+ const exec = mock(() => ssOutput(process.pid, 4096));
90
+ expect(resolvePort(url, exec as never)).toBe(4096);
91
+ });
92
+
93
+ it("URL.port is empty string for default HTTPS port 443", () => {
94
+ const url = new URL("https://127.0.0.1:443");
95
+ expect(url.port).toBe("");
96
+ const exec = mock(() => ssOutput(process.pid, 4096));
97
+ expect(resolvePort(url, exec as never)).toBe(4096);
98
+ });
99
+
100
+ it("does not use ss fallback when URL port is valid", () => {
101
+ const exec = mock(() => ssOutput(process.pid, 9999));
102
+ expect(resolvePort(new URL("http://127.0.0.1:4096"), exec as never)).toBe(4096);
103
+ expect(exec).not.toHaveBeenCalled();
104
+ });
105
+ });
106
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { execFileSync } from "node:child_process";
2
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
2
  import { tool } from "@opencode-ai/plugin/tool";
3
+ import { resolvePort } from "./port";
4
4
 
5
5
  const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
6
6
 
@@ -12,44 +12,46 @@ async function call(path: string, init?: RequestInit) {
12
12
  }
13
13
 
14
14
  export default async (input: { serverUrl: URL }) => {
15
- const shellPid = process.env.OC_SHELL_PID;
16
15
  const registryDir = process.env.OC_REGISTRY;
17
- const file = shellPid && registryDir ? `${registryDir}/${shellPid}.json` : null;
18
16
  let activeSessionID: string | null = null;
17
+ let _activeFile: string | null = null;
19
18
 
20
- const update = (patch: Record<string, unknown>) => {
19
+ const registryFile = (sessionID: string) =>
20
+ registryDir ? `${registryDir}/${sessionID}.json` : null;
21
+
22
+ const update = (sessionID: string, patch: Record<string, unknown>) => {
23
+ const file = registryFile(sessionID);
21
24
  if (!file) return;
22
25
  try {
23
- const data = JSON.parse(readFileSync(file, "utf-8"));
26
+ let data: Record<string, unknown> = {};
27
+ try {
28
+ data = JSON.parse(readFileSync(file, "utf-8"));
29
+ } catch {
30
+ data = { pid: process.pid, dir: process.cwd(), started: new Date().toISOString() };
31
+ }
24
32
  Object.assign(data, patch);
25
33
  writeFileSync(file, `${JSON.stringify(data)}\n`);
34
+ registryFiles.add(file);
26
35
  } catch {}
27
36
  };
28
37
 
38
+ let portWarningLogged = false;
29
39
  const currentPort = () => {
30
- const value = Number.parseInt(input.serverUrl.port, 10);
31
- if (Number.isFinite(value) && value > 0) return value;
32
-
33
- try {
34
- const output = execFileSync("ss", ["-tlnp"], { encoding: "utf-8" });
35
- for (const line of output.split("\n")) {
36
- if (!line.includes(`pid=${process.pid}`)) continue;
37
- const parts = line.trim().split(/\s+/);
38
- const local = parts[3];
39
- const match = local?.match(/:(\d+)$/);
40
- if (!match) continue;
41
- const port = Number.parseInt(match[1], 10);
42
- if (Number.isFinite(port) && port > 0) return port;
43
- }
44
- } catch {}
45
-
46
- return null;
40
+ const port = resolvePort(input.serverUrl);
41
+ if (!port && !portWarningLogged) {
42
+ portWarningLogged = true;
43
+ console.error(
44
+ `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
45
+ );
46
+ }
47
+ if (port) portWarningLogged = false;
48
+ return port;
47
49
  };
48
50
 
49
- const syncPort = () => {
51
+ const syncPort = (sessionID?: string) => {
50
52
  const value = currentPort();
51
53
  if (!value) return false;
52
- update({ port: value });
54
+ if (sessionID) update(sessionID, { port: value });
53
55
  return true;
54
56
  };
55
57
 
@@ -64,17 +66,49 @@ export default async (input: { serverUrl: URL }) => {
64
66
  }
65
67
  };
66
68
 
67
- if (file) {
69
+ // Track all registry files this process created, clean up on exit
70
+ const registryFiles = new Set<string>();
71
+
72
+ if (registryDir) {
68
73
  syncPort();
69
74
  const timer = setInterval(() => {
70
- if (syncPort()) clearInterval(timer);
75
+ if (syncPort(activeSessionID ?? undefined)) clearInterval(timer);
71
76
  }, 1000);
77
+
78
+ // Heartbeat: re-subscribe every 2 minutes to refresh envoy_sessions TTL (5-min)
79
+ const heartbeatInterval = setInterval(
80
+ () => {
81
+ if (!activeSessionID) return;
82
+ const port = currentPort();
83
+ if (!port) return;
84
+ call("/v1/interests/subscribe", {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({
88
+ session_id: activeSessionID,
89
+ dir: process.cwd(),
90
+ topics: [`notifications.agent.${activeSessionID}`],
91
+ port,
92
+ }),
93
+ }).catch(() => {});
94
+ },
95
+ 2 * 60 * 1000
96
+ );
97
+
98
+ process.on("exit", () => {
99
+ clearInterval(heartbeatInterval);
100
+ for (const f of registryFiles) {
101
+ try {
102
+ unlinkSync(f);
103
+ } catch {}
104
+ }
105
+ });
72
106
  }
73
107
 
74
108
  return {
75
- event: file
109
+ event: registryDir
76
110
  ? async ({ event }: { event: { type?: string; properties?: Record<string, unknown> } }) => {
77
- syncPort();
111
+ if (activeSessionID) syncPort(activeSessionID);
78
112
 
79
113
  if (
80
114
  event.type === "session.status" &&
@@ -83,15 +117,29 @@ export default async (input: { serverUrl: URL }) => {
83
117
  const sessionID = event.properties?.sessionID as string | undefined;
84
118
  if (sessionID && sessionID !== activeSessionID) {
85
119
  activeSessionID = sessionID;
120
+ _activeFile = registryFile(sessionID);
86
121
  const session = await fetchSession(sessionID);
87
- if (session) update({ session });
122
+ if (session) update(sessionID, { session });
123
+ syncPort(sessionID);
124
+ const port = currentPort();
125
+ if (port) {
126
+ call("/v1/interests/subscribe", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ session_id: sessionID,
131
+ dir: process.cwd(),
132
+ topics: [`notifications.agent.${sessionID}`],
133
+ port,
134
+ }),
135
+ }).catch(() => {});
136
+ }
88
137
  }
89
138
  }
90
-
91
139
  if (event.type === "session.updated") {
92
140
  const info = event.properties?.info as { id?: string; title?: string } | undefined;
93
141
  if (info && info.id === activeSessionID) {
94
- update({ session: { id: info.id, title: info.title || "" } });
142
+ update(info.id, { session: { id: info.id, title: info.title || "" } });
95
143
  }
96
144
  }
97
145
 
@@ -99,18 +147,21 @@ export default async (input: { serverUrl: URL }) => {
99
147
  const sessionID = event.properties?.sessionID as string | undefined;
100
148
  if (sessionID && sessionID === activeSessionID) {
101
149
  const session = await fetchSession(sessionID);
102
- if (session) update({ session });
150
+ if (session) update(sessionID, { session });
103
151
  }
104
152
  }
105
153
  }
106
154
  : undefined,
107
155
  tool: {
108
156
  envoy_subscribe: tool({
109
- description: "Subscribe this session to envoy notification topics",
157
+ description:
158
+ "Subscribe this session to Envoy notification topics. Use exact NATS-style topic strings such as notifications.agent.<session_id>, notifications.github.<owner>.<repo>.pr, notifications.github.<owner>.<repo>.issue, notifications.github.<owner>.<repo>.comment, notifications.github.<owner>.<repo>.ci, notifications.slack.<team_id>.<channel_id>.message, or notifications.slack.<team_id>.<channel_id>.mention. Use this when a session should RECEIVE future events.",
110
159
  args: {
111
160
  topics: tool.schema
112
161
  .array(tool.schema.string())
113
- .describe("NATS-style topic patterns to subscribe to"),
162
+ .describe(
163
+ "NATS-style topic patterns to subscribe to. Examples: notifications.agent.ses_123, notifications.github.trajectory-labs-pbc.agent-c.pr, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
164
+ ),
114
165
  },
115
166
  async execute(args, ctx) {
116
167
  ctx.metadata({ title: "Envoy subscribe" });
@@ -121,12 +172,14 @@ export default async (input: { serverUrl: URL }) => {
121
172
  session_id: ctx.sessionID,
122
173
  dir: ctx.directory,
123
174
  topics: args.topics,
175
+ port: currentPort() ?? 0,
124
176
  }),
125
177
  });
126
178
  },
127
179
  }),
128
180
  envoy_unsubscribe: tool({
129
- description: "Unsubscribe this session from envoy topics, or all if omitted",
181
+ description:
182
+ "Unsubscribe this session from Envoy topics, or remove all current subscriptions if topics are omitted.",
130
183
  args: {
131
184
  topics: tool.schema
132
185
  .array(tool.schema.string())
@@ -146,7 +199,8 @@ export default async (input: { serverUrl: URL }) => {
146
199
  },
147
200
  }),
148
201
  envoy_list: tool({
149
- description: "List envoy subscriptions for this session",
202
+ description:
203
+ "List the current Envoy topic subscriptions for this session so you can confirm the exact topic shapes that are active.",
150
204
  args: {},
151
205
  async execute(_args, ctx) {
152
206
  ctx.metadata({ title: "Envoy list" });
@@ -154,10 +208,15 @@ export default async (input: { serverUrl: URL }) => {
154
208
  },
155
209
  }),
156
210
  envoy_send: tool({
157
- description: "Send an envoy agent-to-agent message to another session",
211
+ description:
212
+ "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.",
158
213
  args: {
159
- target_session: tool.schema.string().describe("Target session ID"),
160
- message: tool.schema.string().describe("Message to deliver"),
214
+ target_session: tool.schema
215
+ .string()
216
+ .describe("Target OpenCode session ID, e.g. ses_2e6ca3034ffejVikSZ8mDwk0mR"),
217
+ message: tool.schema
218
+ .string()
219
+ .describe("Message body to deliver to that session as a new user turn/notification"),
161
220
  },
162
221
  async execute(args, ctx) {
163
222
  ctx.metadata({ title: "Envoy send" });
@@ -172,6 +231,28 @@ export default async (input: { serverUrl: URL }) => {
172
231
  });
173
232
  },
174
233
  }),
234
+ envoy_publish: tool({
235
+ description:
236
+ "Publish an Envoy message to any topic. Use for broadcast to named topics like notifications.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).",
237
+ args: {
238
+ topic: tool.schema
239
+ .string()
240
+ .describe("NATS-style topic to publish to, e.g. notifications.legion.controller"),
241
+ message: tool.schema.string().describe("Message body to broadcast"),
242
+ },
243
+ async execute(args, ctx) {
244
+ ctx.metadata({ title: "Envoy publish" });
245
+ return call("/v1/messages/publish", {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({
249
+ source_session: ctx.sessionID,
250
+ topic: args.topic,
251
+ message: args.message,
252
+ }),
253
+ });
254
+ },
255
+ }),
175
256
  },
176
257
  };
177
258
  };
package/src/port.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Resolve the serve port from the server URL, with ss(8) fallback.
5
+ *
6
+ * Priority:
7
+ * 1. URL.port (standard: non-empty for non-default ports)
8
+ * 2. ss -tlnp PID match (finds listening port by process ID)
9
+ * 3. null (caller decides how to handle)
10
+ */
11
+ export function resolvePort(
12
+ serverUrl: URL,
13
+ exec: typeof execFileSync = execFileSync
14
+ ): number | null {
15
+ // Try URL.port first (standard path for non-default ports like 4096, 13381)
16
+ const urlPort = Number.parseInt(serverUrl.port, 10);
17
+ if (Number.isFinite(urlPort) && urlPort > 0) return urlPort;
18
+
19
+ // Fallback: find listening port via ss(8) by PID
20
+ try {
21
+ const output = exec("ss", ["-tlnp"], { encoding: "utf-8" }) as string;
22
+ for (const line of output.split("\n")) {
23
+ if (!line.includes(`pid=${process.pid}`)) continue;
24
+ const parts = line.trim().split(/\s+/);
25
+ const local = parts[3];
26
+ const match = local?.match(/:(\d+)$/);
27
+ if (!match) continue;
28
+ const port = Number.parseInt(match[1], 10);
29
+ if (Number.isFinite(port) && port > 0) return port;
30
+ }
31
+ } catch {}
32
+
33
+ return null;
34
+ }