@sjawhar/opencode-legion-envoy 0.5.2 → 0.6.1

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,11 +1,14 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
- "main": "src/server.ts",
5
+ "main": "dist/src/server.js",
6
6
  "exports": {
7
+ ".": {
8
+ "import": "./dist/src/server.js"
9
+ },
7
10
  "./server": {
8
- "import": "./src/server.ts"
11
+ "import": "./dist/src/server.js"
9
12
  },
10
13
  "./tui": {
11
14
  "import": "./src/tui.tsx"
@@ -15,15 +18,19 @@
15
18
  "type": "git",
16
19
  "url": "https://github.com/sjawhar/legion"
17
20
  },
18
- "types": "src/server.ts",
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "!src/**/__tests__"
25
+ ],
19
26
  "scripts": {
27
+ "build": "bun build src/server.ts bin/dispatch-mcp-shim.ts --outdir dist --target bun --format esm --external '@opencode-ai/*'",
28
+ "prepack": "bun run build",
20
29
  "typecheck": "bunx tsc --noEmit",
21
30
  "test": "bun test",
22
31
  "lint": "bunx biome check src/"
23
32
  },
24
33
  "dependencies": {
25
- "@legion/contracts": "workspace:*",
26
- "@legion/envoy-client": "workspace:*",
27
34
  "@opencode-ai/plugin": "~1.14.46"
28
35
  },
29
36
  "peerDependencies": {
@@ -32,6 +39,8 @@
32
39
  },
33
40
  "devDependencies": {
34
41
  "@biomejs/biome": "^2.3.14",
42
+ "@legion/contracts": "0.10.0",
43
+ "@legion/envoy-client": "0.1.0",
35
44
  "@types/bun": "latest",
36
45
  "solid-js": "^1.9.0",
37
46
  "typescript": "^5.3.0"
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { messageFor } from "@legion/envoy-client/errors";
4
5
  import { logger } from "../log";
5
6
  import { type EnvoyConfig, EnvoyConfigSchema } from "./schema";
6
7
 
@@ -23,7 +24,7 @@ function readConfigFile(filePath: string): EnvoyConfig | null {
23
24
  }
24
25
  return parsed.data as EnvoyConfig;
25
26
  } catch (error) {
26
- const message = error instanceof Error ? error.message : String(error);
27
+ const message = messageFor(error);
27
28
  logger.warn(`[envoy-plugin] Failed to load config at ${filePath}: ${message}`);
28
29
  return null;
29
30
  }
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  import type { DispatchConfig } from "./config";
3
4
 
@@ -38,10 +39,20 @@ export interface BuildDispatchMcpEntryOptions {
38
39
  const DEFAULT_SERVER_URL = "http://localhost:8766";
39
40
 
40
41
  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");
42
+ // Source layout: this module runs from src/ and the shim wrapper lives at
43
+ // ../bin/dispatch-mcp-shim.ts. Packed layout: this module is bundled to
44
+ // dist/src/server.js and the self-contained shim bundle lives at
45
+ // dist/bin/dispatch-mcp-shim.js — same ../bin relationship, built artifact.
46
+ const packageRoot = path.join(import.meta.dir, "..");
47
+ const candidates = [
48
+ path.join(packageRoot, "bin", "dispatch-mcp-shim.js"),
49
+ path.join(packageRoot, "bin", "dispatch-mcp-shim.ts"),
50
+ ];
51
+ const found = candidates.find((candidate) => existsSync(candidate));
52
+ if (!found) {
53
+ throw new Error(`dispatch MCP shim not found; tried: ${candidates.join(", ")}`);
54
+ }
55
+ return found;
45
56
  }
46
57
 
47
58
  /**
@@ -1,4 +1,4 @@
1
- // Auto-subscription wiring for the envoy_dispatch MCP tool (Dispatch AC#4).
1
+ // Auto-subscription wiring for the envoy_dispatch MCP tool.
2
2
  //
3
3
  // When an agent opens a Dispatch thread via the envoy_dispatch MCP tool, the
4
4
  // human answers by commenting on the resulting GitHub sub-issue. For the agent
package/src/port.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { portFromSsOutput } from "./ss";
2
3
 
3
4
  type ExecFn = (
4
5
  command: string,
@@ -33,17 +34,9 @@ export async function resolvePort(
33
34
  // Fallback: find listening port via ss(8) by PID
34
35
  try {
35
36
  const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
36
- for (const line of output.split("\n")) {
37
- if (!line.includes(`pid=${process.pid}`)) continue;
38
- const parts = line.trim().split(/\s+/);
39
- const local = parts[3];
40
- const match = local?.match(/:(\d+)$/);
41
- if (!match) continue;
42
- const port = Number.parseInt(match[1], 10);
43
- if (Number.isFinite(port) && port > 0) return port;
44
- }
37
+ const port = portFromSsOutput(output, process.pid);
38
+ if (port !== null) return port;
45
39
  } catch {}
46
40
 
47
41
  return null;
48
42
  }
49
- // trigger publish
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { agentSubject } from "@legion/contracts";
2
2
  import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
3
+ import { machineID } from "@legion/envoy-client/machine";
3
4
  import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
4
5
  import { createEnvoyClient } from "@legion/envoy-client/transport";
5
6
  import { tool } from "@opencode-ai/plugin/tool";
@@ -41,7 +42,10 @@ export default async (input: { serverUrl: URL }) => {
41
42
  if (!port && !portWarningLogged) {
42
43
  portWarningLogged = true;
43
44
  logger.error(
44
- `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
45
+ [
46
+ `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href},`,
47
+ `pid=${process.pid}`,
48
+ ].join(" ")
45
49
  );
46
50
  }
47
51
  if (port) {
@@ -218,10 +222,10 @@ export default async (input: { serverUrl: URL }) => {
218
222
  input: { tool: string; sessionID: string; callID: string; args: unknown },
219
223
  output: { title: string; output: string; metadata: unknown }
220
224
  ) => {
221
- // Dispatch AC#4: when this session opens a Dispatch thread via the
222
- // envoy_dispatch MCP tool, auto-subscribe it to the thread's GitHub topic
223
- // so the human's reply is delivered back through Envoy. Best-effort — a
224
- // subscribe failure must never surface to the model or fail the tool call.
225
+ // When this session opens a Dispatch thread via the envoy_dispatch MCP
226
+ // tool, auto-subscribe it to the thread's GitHub topic so the human's
227
+ // reply is delivered back through Envoy. Best-effort — a subscribe
228
+ // failure must never surface to the model or fail the tool call.
225
229
  const topic = dispatchSubscriptionTopic(input.tool, output.output);
226
230
  if (!topic) return;
227
231
  try {
@@ -234,9 +238,8 @@ export default async (input: { serverUrl: URL }) => {
234
238
  driving: true,
235
239
  });
236
240
  } catch (err) {
237
- logger.warn(
238
- `[envoy-plugin] dispatch auto-subscribe failed: ${err instanceof Error ? err.message : String(err)}`
239
- );
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ logger.warn(`[envoy-plugin] dispatch auto-subscribe failed: ${message}`);
240
243
  }
241
244
  },
242
245
  // Cleanup hook (used by tests; production relies on process 'exit').
@@ -325,7 +328,7 @@ export default async (input: { serverUrl: URL }) => {
325
328
  return JSON.stringify(
326
329
  {
327
330
  session_id: sessionID,
328
- machine_id: process.env.HOSTNAME || "unknown",
331
+ machine_id: machineID(),
329
332
  port,
330
333
  dir: ctx.directory,
331
334
  },
package/src/ss.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Parse the listening TCP port for `pid` from `ss -tlnp` output.
3
+ *
4
+ * Shared by the server-side and TUI-side port resolvers, which differ only in
5
+ * how they discover the pid and whether they exec ss(8) sync or async.
6
+ */
7
+ export function portFromSsOutput(output: string, pid: number): number | null {
8
+ const pidPattern = new RegExp(`\\bpid=${pid}\\b`);
9
+ for (const line of output.split("\n")) {
10
+ if (!pidPattern.test(line)) continue;
11
+ const parts = line.trim().split(/\s+/);
12
+ const local = parts[3];
13
+ const match = local?.match(/:(\d+)$/);
14
+ if (!match) continue;
15
+ const port = Number.parseInt(match[1], 10);
16
+ if (Number.isFinite(port) && port > 0) return port;
17
+ }
18
+ return null;
19
+ }
package/src/tui-port.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { portFromSsOutput } from "./ss";
2
3
 
3
4
  type ExecSyncFn = (command: string, args: string[], options: { encoding: string }) => string;
4
5
 
@@ -36,19 +37,10 @@ export function resolveCurrentProcessPort(exec: ExecSyncFn = defaultExecSync): n
36
37
 
37
38
  function resolveProcessPort(pid: number, exec: ExecSyncFn = defaultExecSync): number | null {
38
39
  try {
39
- const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
40
- for (const line of output.split("\n")) {
41
- if (!line.includes(`pid=${pid}`)) continue;
42
- const parts = line.trim().split(/\s+/);
43
- const local = parts[3];
44
- const match = local?.match(/:(\d+)$/);
45
- if (!match) continue;
46
- const port = Number.parseInt(match[1], 10);
47
- if (Number.isFinite(port) && port > 0) return port;
48
- }
49
- } catch {}
50
-
51
- return null;
40
+ return portFromSsOutput(exec("ss", ["-tlnp"], { encoding: "utf-8" }), pid);
41
+ } catch {
42
+ return null;
43
+ }
52
44
  }
53
45
 
54
46
  export function resolveSessionProcessPort(
package/AGENTS.md DELETED
@@ -1,34 +0,0 @@
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/server.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send`, `envoy_publish`, `envoy_role_set`, `envoy_whoami`, `envoy_sessions` |
16
- | Packaging metadata | `package.json` | npm identity, `exports` map, scripts |
17
- | TUI: `/whoami` + sidebar | `src/tui.tsx` | slash command + session-id/port sidebar; loaded via the `./tui` export. Ships as `.tsx` source (no build/`dist`) — Bun transpiles it natively at load, so `@opentui/core` + `@opentui/solid` MUST be `peerDependencies` (not `devDependencies`) so the `@jsxImportSource @opentui/solid` runtime resolves in the consumer's install tree |
18
- | Host rollout helper | `scripts/sync-host.sh` | sync packed release tarball + shim to remote host |
19
- | Dispatch MCP + auto-subscribe | `src/dispatch-mcp.ts`, `src/dispatch-subscribe.ts` | injects the dispatch MCP server (shim); `tool.execute.after` auto-subscribes the caller to the new thread's topic so answers route back (Dispatch AC#4) |
20
-
21
- ## Critical conventions
22
-
23
- - Tool descriptions must be self-describing enough that agents can infer correct topic formats.
24
- - Slack examples must use real `team_id` values, not workspace slugs.
25
- - This package owns the session-registry/port-backfill behavior now; do not split that back into a second plugin casually.
26
- - Keep the plugin source-of-truth here even if a dotfiles shim is still used for rollout convenience.
27
-
28
- ## Topic reminders
29
-
30
- - Agent: `notifications.agent.<session_id>`
31
- - GitHub: `notifications.github.<owner>.<repo>.<kind>`
32
- - Slack: `notifications.slack.<team_id>.<channel_id>.<message|mention>`
33
-
34
- If you are unsure what a session is subscribed to, use `envoy_list()`.
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env bun
2
- // envoy-plugin local MCP shim.
3
- //
4
- // Spawned by OpenCode as a `type: "local"` MCP transport. Reads JSON-RPC
5
- // messages from stdin (newline-delimited), forwards each to the remote
6
- // dispatch server's Streamable HTTP /mcp endpoint with a fresh GitHub
7
- // bearer minted via the user's `gh` shim, and writes responses to stdout.
8
- //
9
- // Token rotation is invisible to OpenCode — the shim handles 50-minute
10
- // refresh cycles + immediate retry on 401. This avoids the "MCP dies
11
- // after 1 hour" failure mode of static-header configurations.
12
-
13
- import * as readline from "node:readline";
14
- import {
15
- createBridge,
16
- defaultGhTokenGetter,
17
- type JsonRpcRequest,
18
- } from "../src/dispatch-mcp-bridge";
19
-
20
- const remoteUrl = process.env.DISPATCH_MCP_URL;
21
- if (!remoteUrl) {
22
- process.stderr.write("envoy-dispatch shim: DISPATCH_MCP_URL is required\n");
23
- process.exit(1);
24
- }
25
-
26
- const bridge = createBridge({
27
- remoteUrl,
28
- getToken: defaultGhTokenGetter,
29
- });
30
-
31
- const rl = readline.createInterface({ input: process.stdin });
32
-
33
- let inflight = 0;
34
- let closed = false;
35
-
36
- // Serialize incoming requests. MCP requires the initialize handshake to
37
- // complete before any tool calls are processed; running rl.on("line")
38
- // callbacks in parallel would race tools/call against initialize and hit
39
- // `invalid during session initialization` from the server. Even after
40
- // init, sequencing keeps the wire ordering deterministic, which is what
41
- // OpenCode expects for stdio MCP transports.
42
- let chain: Promise<void> = Promise.resolve();
43
-
44
- function maybeExit(): void {
45
- if (closed && inflight === 0) process.exit(0);
46
- }
47
-
48
- rl.on("line", (line) => {
49
- const trimmed = line.trim();
50
- if (!trimmed) return;
51
- inflight++;
52
- chain = chain.then(async () => {
53
- try {
54
- const request = JSON.parse(trimmed) as JsonRpcRequest;
55
- const response = await bridge.handle(request);
56
- if (response !== null) {
57
- process.stdout.write(`${JSON.stringify(response)}\n`);
58
- }
59
- } catch (err) {
60
- const msg = err instanceof Error ? err.message : String(err);
61
- process.stderr.write(`envoy-dispatch shim: ${msg}\n`);
62
- } finally {
63
- inflight--;
64
- maybeExit();
65
- }
66
- });
67
- });
68
-
69
- rl.on("close", () => {
70
- closed = true;
71
- maybeExit();
72
- });
73
-
74
- process.on("SIGTERM", () => process.exit(0));
75
- process.on("SIGINT", () => process.exit(0));
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- host="${1:?usage: sync-host.sh user@host}"
5
-
6
- PLUGIN_DIR="legion/default/packages/envoy-plugin"
7
- PLUGIN_REF="file://{env:HOME}/${PLUGIN_DIR}"
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
- # Update tui.json: ensure file:// ref is present in the TUI plugin list.
57
- # tui.json is a separate config file from opencode.json; opencode loads TUI
58
- # plugins (slash commands, sidebar slots) only from here.
59
- ssh "$host" "
60
- TUI_CONFIG=\$(readlink -f \$HOME/.config/opencode/tui.json 2>/dev/null || echo \$HOME/.config/opencode/tui.json)
61
- if [ -f \"\$TUI_CONFIG\" ]; then
62
- jq --arg ref '$PLUGIN_REF' \\
63
- '(.plugin // []) |= ((map(if (test(\"opencode-legion-envoy\") and (test(\"^file://\") | not)) then \$ref else . end)) | if any(. == \$ref) then . else . + [\$ref] end)' \\
64
- \"\$TUI_CONFIG\" > /tmp/tui.json.tmp && mv /tmp/tui.json.tmp \"\$TUI_CONFIG\"
65
- echo \"Updated tui.json: \$TUI_CONFIG\"
66
- else
67
- mkdir -p \$(dirname \"\$TUI_CONFIG\")
68
- jq -n --arg ref '$PLUGIN_REF' '{\"\\\$schema\":\"https://opencode.ai/tui.json\",\"plugin\":[\$ref]}' > \"\$TUI_CONFIG\"
69
- echo \"Created tui.json: \$TUI_CONFIG\"
70
- fi
71
- "
72
-
73
- echo "Done: $host envoy-plugin synced from release $tag"
@@ -1,56 +0,0 @@
1
- import { describe, expect, it, mock } from "bun:test";
2
- import { copyNative, copyToClipboard } from "../clipboard";
3
-
4
- describe("copyToClipboard", () => {
5
- it("copies via the renderer OSC 52 writer and does not fall back when it succeeds", () => {
6
- const copyToClipboardOSC52 = mock((_text: string) => true);
7
- const ok = copyToClipboard("ses_abc", { copyToClipboardOSC52 });
8
- expect(ok).toBe(true);
9
- expect(copyToClipboardOSC52).toHaveBeenCalledWith("ses_abc");
10
- });
11
- });
12
-
13
- describe("copyNative", () => {
14
- it("uses wl-copy on Linux/Wayland when available", () => {
15
- const run = mock((_cmd: string, _args: string[], _text: string) => true);
16
- const ok = copyNative("ses_abc", {
17
- os: "linux",
18
- env: { WAYLAND_DISPLAY: "wayland-0" },
19
- which: (cmd) => cmd === "wl-copy",
20
- run,
21
- });
22
- expect(ok).toBe(true);
23
- expect(run).toHaveBeenCalledWith("wl-copy", [], "ses_abc");
24
- });
25
-
26
- it("falls back to xclip on Linux/X11", () => {
27
- const run = mock((_cmd: string, _args: string[], _text: string) => true);
28
- const ok = copyNative("ses_abc", {
29
- os: "linux",
30
- env: {},
31
- which: (cmd) => cmd === "xclip",
32
- run,
33
- });
34
- expect(ok).toBe(true);
35
- expect(run).toHaveBeenCalledWith("xclip", ["-selection", "clipboard"], "ses_abc");
36
- });
37
-
38
- it("falls back to xsel when xclip is missing", () => {
39
- const run = mock((_cmd: string, _args: string[], _text: string) => true);
40
- copyNative("x", { os: "linux", env: {}, which: (cmd) => cmd === "xsel", run });
41
- expect(run).toHaveBeenCalledWith("xsel", ["--clipboard", "--input"], "x");
42
- });
43
-
44
- it("uses osascript on macOS with escaped quotes", () => {
45
- const run = mock((_cmd: string, _args: string[], _text: string) => true);
46
- copyNative('a"b\\c', { os: "darwin", which: () => true, run });
47
- expect(run).toHaveBeenCalledWith("osascript", ["-e", 'set the clipboard to "a\\"b\\\\c"'], "");
48
- });
49
-
50
- it("returns false on Linux when no clipboard tool is installed", () => {
51
- const run = mock(() => true);
52
- const ok = copyNative("x", { os: "linux", env: {}, which: () => false, run });
53
- expect(ok).toBe(false);
54
- expect(run).not.toHaveBeenCalled();
55
- });
56
- });