@sjawhar/opencode-legion-envoy 0.1.10 → 0.2.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.
@@ -1,4 +1,8 @@
1
1
  import { tool } from "@opencode-ai/plugin/tool";
2
+ import { loadEnvoyConfig } from "./config";
3
+ import { buildDispatchMcpEntry, injectEnvoyMcp } from "./dispatch-mcp";
4
+ import { dispatchSubscriptionTopic } from "./dispatch-subscribe";
5
+ import { logger } from "./log";
2
6
  import { resolvePort } from "./port";
3
7
 
4
8
  const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
@@ -17,8 +21,17 @@ async function call(path: string, init?: RequestInit) {
17
21
  }
18
22
 
19
23
  export default async (input: { serverUrl: URL }) => {
24
+ const cwd = process.cwd();
25
+ const config = await loadEnvoyConfig(cwd);
20
26
  let activeSessionID: string | null = null;
21
27
  let activeSessionTitle: string | null = null;
28
+ // All sessions that have become busy in this serve instance. The heartbeat
29
+ // refreshes the envoy_sessions TTL for ALL of them — a single serve hosts many
30
+ // sessions, so tracking only the most-recently-active one lets idle siblings
31
+ // expire out of the registry and become undeliverable.
32
+ const trackedSessions = new Map<string, { title: string | null }>();
33
+ // Guard so sibling re-adoption (after a serve restart) runs at most once.
34
+ let readoptDone = false;
22
35
  /** Cached port — resolved asynchronously, null until first successful resolution. */
23
36
  let resolvedPort: number | null = null;
24
37
 
@@ -27,7 +40,7 @@ export default async (input: { serverUrl: URL }) => {
27
40
  const port = await resolvePort(input.serverUrl);
28
41
  if (!port && !portWarningLogged) {
29
42
  portWarningLogged = true;
30
- console.error(
43
+ logger.error(
31
44
  `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
32
45
  );
33
46
  }
@@ -71,26 +84,70 @@ export default async (input: { serverUrl: URL }) => {
71
84
  // Fire an immediate async attempt (non-blocking)
72
85
  syncPort().catch(() => {});
73
86
 
74
- // Heartbeat: re-subscribe every 2 minutes to refresh envoy_sessions TTL (5-min)
75
- const heartbeatInterval = setInterval(
76
- () => {
77
- if (!activeSessionID) return;
78
- const port = currentPort();
79
- if (!port) return;
80
- call("/v1/interests/subscribe", {
81
- method: "POST",
82
- headers: { "Content-Type": "application/json" },
83
- body: JSON.stringify({
84
- session_id: activeSessionID,
85
- dir: process.cwd(),
86
- topics: [`notifications.agent.${activeSessionID}`],
87
- port,
88
- title: activeSessionTitle ?? "",
89
- }),
90
- }).catch(() => {});
91
- },
92
- 2 * 60 * 1000
93
- );
87
+ const subscribeSession = (sessionID: string, title: string | null, port: number) =>
88
+ call("/v1/interests/subscribe", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({
92
+ session_id: sessionID,
93
+ dir: cwd,
94
+ topics: [`notifications.agent.${sessionID}`],
95
+ port,
96
+ title: title ?? "",
97
+ }),
98
+ }).catch(() => {});
99
+
100
+ // After a serve restart, sessions that were live in the previous serve instance
101
+ // do NOT re-register on their own (registration is gated on a session going
102
+ // busy), so an idle session waiting to RECEIVE a message silently falls out of
103
+ // the registry. Recover them once, on first activity: read the live registry and
104
+ // re-subscribe siblings that share this serve's machine + dir at the new port.
105
+ const readoptSiblings = async (selfSessionID: string) => {
106
+ if (readoptDone) return;
107
+ const port = currentPort();
108
+ if (!port) return;
109
+ try {
110
+ const res = await call("/v1/sessions");
111
+ const sessions = JSON.parse(res) as Array<{
112
+ session_id: string;
113
+ machine_id: string;
114
+ dir: string;
115
+ title?: string;
116
+ }>;
117
+ // Authoritative machine id for this serve = the listener-stamped machine of
118
+ // our own active session. Only adopt siblings that match it (and our dir) to
119
+ // avoid hijacking a same-path session that lives on another machine.
120
+ const self = sessions.find((s) => s.session_id === selfSessionID);
121
+ if (!self) return;
122
+ readoptDone = true;
123
+ for (const s of sessions) {
124
+ if (s.machine_id !== self.machine_id) continue;
125
+ if (s.dir !== cwd) continue;
126
+ if (trackedSessions.has(s.session_id)) continue;
127
+ trackedSessions.set(s.session_id, { title: s.title ?? null });
128
+ subscribeSession(s.session_id, s.title ?? null, port);
129
+ }
130
+ } catch {}
131
+ };
132
+
133
+ // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
134
+ // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
135
+ // just the most recently active one. Interval is env-tunable for tests/tuning.
136
+ const rawHeartbeatMs = Number(process.env.ENVOY_HEARTBEAT_MS);
137
+ const heartbeatMs =
138
+ Number.isFinite(rawHeartbeatMs) && rawHeartbeatMs > 0
139
+ ? Math.max(rawHeartbeatMs, 25)
140
+ : 2 * 60 * 1000;
141
+ const heartbeatInterval = setInterval(() => {
142
+ const port = currentPort();
143
+ if (!port) return;
144
+ for (const [sessionID, info] of trackedSessions) {
145
+ subscribeSession(sessionID, info.title, port);
146
+ }
147
+ // Retry sibling re-adoption until the registry shows our own session.
148
+ if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
149
+ }, heartbeatMs);
150
+ heartbeatInterval.unref?.();
94
151
 
95
152
  process.on("exit", () => {
96
153
  clearInterval(timer);
@@ -98,6 +155,22 @@ export default async (input: { serverUrl: URL }) => {
98
155
  });
99
156
 
100
157
  return {
158
+ config: (cfg: { mcp?: Record<string, unknown> } & Record<string, unknown>) => {
159
+ // Inject the envoy MCP entry into the OpenCode config when
160
+ // dispatch is enabled. Centralizing this in the plugin (instead of
161
+ // each user's opencode.json) means:
162
+ // 1. Registration is gated by `dispatch.enabled`
163
+ // 2. The bearer token is sourced per-CWD via the user's gh shim
164
+ // (no env coordination needed)
165
+ // 3. Token rotation happens transparently inside the shim
166
+ // subprocess — OpenCode never sees an expired token
167
+ const entry = buildDispatchMcpEntry({
168
+ dispatch: config.dispatch,
169
+ });
170
+ if (!entry) return;
171
+ const { warning } = injectEnvoyMcp(cfg, entry);
172
+ if (warning) logger.warn(warning);
173
+ },
101
174
  event: async ({
102
175
  event,
103
176
  }: {
@@ -113,6 +186,9 @@ export default async (input: { serverUrl: URL }) => {
113
186
  if (sessionID && sessionID !== activeSessionID) {
114
187
  activeSessionID = sessionID;
115
188
  activeSessionTitle = null;
189
+ if (!trackedSessions.has(sessionID)) {
190
+ trackedSessions.set(sessionID, { title: null });
191
+ }
116
192
  await syncPort();
117
193
  const port = currentPort();
118
194
  // Fetch title — best-effort, non-blocking for initial subscribe
@@ -122,37 +198,84 @@ export default async (input: { serverUrl: URL }) => {
122
198
  return t;
123
199
  });
124
200
  if (port) {
125
- call("/v1/interests/subscribe", {
126
- method: "POST",
127
- headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify({
129
- session_id: sessionID,
130
- dir: process.cwd(),
131
- topics: [`notifications.agent.${sessionID}`],
132
- port,
133
- title: activeSessionTitle ?? "",
134
- }),
135
- }).catch(() => {});
136
- // After title arrives, send one follow-up subscribe with title populated
201
+ // Await so our own session is persisted in the registry before
202
+ // readoptSiblings reads it back (otherwise self may be absent and
203
+ // re-adoption would be skipped).
204
+ await subscribeSession(sessionID, activeSessionTitle, port);
205
+ // After the title arrives, send one follow-up subscribe with it.
137
206
  titlePromise.then((title) => {
138
- if (title && activeSessionID === sessionID) {
139
- call("/v1/interests/subscribe", {
140
- method: "POST",
141
- headers: { "Content-Type": "application/json" },
142
- body: JSON.stringify({
143
- session_id: sessionID,
144
- dir: process.cwd(),
145
- topics: [`notifications.agent.${sessionID}`],
146
- port: currentPort() ?? 0,
147
- title,
148
- }),
149
- }).catch(() => {});
207
+ if (!title) return;
208
+ // Update tracked metadata even if this session is no longer the
209
+ // active one (another session may have become busy meanwhile).
210
+ if (trackedSessions.has(sessionID)) {
211
+ trackedSessions.set(sessionID, { title });
212
+ subscribeSession(sessionID, title, currentPort() ?? 0);
150
213
  }
214
+ if (activeSessionID === sessionID) activeSessionTitle = title;
151
215
  });
216
+ // Recover idle siblings orphaned by a serve restart (retries from the
217
+ // heartbeat until the registry shows our own session).
218
+ readoptSiblings(sessionID).catch(() => {});
219
+ }
220
+ }
221
+ }
222
+
223
+ if (event.type === "session.deleted") {
224
+ const props = event.properties ?? {};
225
+ const deletedID =
226
+ (props.sessionID as string | undefined) ??
227
+ (props.info as { id?: string } | undefined)?.id;
228
+ if (deletedID) {
229
+ // Stop heartbeating a session that no longer exists, so its 5-min
230
+ // envoy_sessions entry expires instead of being kept alive (which would
231
+ // cause delivery attempts to a dead session id on the current port).
232
+ trackedSessions.delete(deletedID);
233
+ if (activeSessionID === deletedID) {
234
+ activeSessionID = null;
235
+ activeSessionTitle = null;
152
236
  }
237
+ // Best-effort: drop the deleted session's interests so routing stops.
238
+ call("/v1/interests/unsubscribe", {
239
+ method: "POST",
240
+ headers: { "Content-Type": "application/json" },
241
+ body: JSON.stringify({ session_id: deletedID, topics: [] }),
242
+ }).catch(() => {});
153
243
  }
154
244
  }
155
245
  },
246
+ "tool.execute.after": async (
247
+ input: { tool: string; sessionID: string; callID: string; args: unknown },
248
+ output: { title: string; output: string; metadata: unknown }
249
+ ) => {
250
+ // Dispatch AC#4: when this session opens a Dispatch thread via the
251
+ // envoy_dispatch MCP tool, auto-subscribe it to the thread's GitHub topic
252
+ // so the human's reply is delivered back through Envoy. Best-effort — a
253
+ // subscribe failure must never surface to the model or fail the tool call.
254
+ const topic = dispatchSubscriptionTopic(input.tool, output.output);
255
+ if (!topic) return;
256
+ try {
257
+ await call("/v1/interests/subscribe", {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({
261
+ session_id: input.sessionID,
262
+ dir: cwd,
263
+ topics: [topic],
264
+ port: currentPort() ?? 0,
265
+ title: activeSessionTitle ?? "",
266
+ }),
267
+ });
268
+ } catch (err) {
269
+ logger.warn(
270
+ `[envoy-plugin] dispatch auto-subscribe failed: ${err instanceof Error ? err.message : String(err)}`
271
+ );
272
+ }
273
+ },
274
+ // Cleanup hook (used by tests; production relies on process 'exit').
275
+ dispose: () => {
276
+ clearInterval(timer);
277
+ clearInterval(heartbeatInterval);
278
+ },
156
279
  tool: {
157
280
  envoy_subscribe: tool({
158
281
  description:
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ type ExecSyncFn = (command: string, args: string[], options: { encoding: string }) => string;
4
+
5
+ /**
6
+ * Parse the port from an OpenCode serve baseUrl.
7
+ *
8
+ * Synchronous and URL-only: the TUI plugin runs in-process with the
9
+ * OpenCode TUI, which always knows the baseUrl of its serve daemon.
10
+ * This is separate from the server-side `resolvePort` helper, which
11
+ * additionally consults `ss(8)` by PID — irrelevant in the TUI process.
12
+ *
13
+ * Returns null when input is missing, malformed, or has no explicit
14
+ * numeric port.
15
+ */
16
+ export function parsePort(baseUrl: string | undefined): number | null {
17
+ if (!baseUrl) return null;
18
+ let parsed: URL;
19
+ try {
20
+ parsed = new URL(baseUrl);
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (!parsed.port) return null;
25
+ const port = Number.parseInt(parsed.port, 10);
26
+ if (!Number.isFinite(port) || port <= 0) return null;
27
+ return port;
28
+ }
29
+
30
+ const defaultExecSync: ExecSyncFn = (command, args, options) =>
31
+ execFileSync(command, args, { encoding: options.encoding as BufferEncoding }) as string;
32
+
33
+ export function resolveCurrentProcessPort(exec: ExecSyncFn = defaultExecSync): number | null {
34
+ return resolveProcessPort(process.pid, exec);
35
+ }
36
+
37
+ function resolveProcessPort(pid: number, exec: ExecSyncFn = defaultExecSync): number | null {
38
+ 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;
52
+ }
53
+
54
+ export function resolveSessionProcessPort(
55
+ sessionID: string,
56
+ exec: ExecSyncFn = defaultExecSync
57
+ ): number | null {
58
+ try {
59
+ const output = exec("ps", ["-eo", "pid=,args="], { encoding: "utf-8" });
60
+ for (const line of output.split("\n")) {
61
+ if (!line.includes(sessionID)) continue;
62
+ if (!/(^|\s)-s\s+/.test(line)) continue;
63
+ const pid = Number.parseInt(line.trim().split(/\s+/, 1)[0] ?? "", 10);
64
+ if (!Number.isFinite(pid) || pid <= 0) continue;
65
+ const port = resolveProcessPort(pid, exec);
66
+ if (port !== null) return port;
67
+ }
68
+ } catch {}
69
+
70
+ return null;
71
+ }
72
+
73
+ export function resolveTuiPort(
74
+ baseUrl: string | undefined,
75
+ sessionID?: string,
76
+ exec: ExecSyncFn = defaultExecSync
77
+ ): number | null {
78
+ return (
79
+ parsePort(baseUrl) ??
80
+ (sessionID ? resolveSessionProcessPort(sessionID, exec) : null) ??
81
+ resolveCurrentProcessPort(exec)
82
+ );
83
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,122 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type {
3
+ TuiPlugin,
4
+ TuiPluginApi,
5
+ TuiPluginModule,
6
+ TuiSlotPlugin,
7
+ } from "@opencode-ai/plugin/tui";
8
+ import { createSignal } from "solid-js";
9
+ import { copyToClipboard } from "./clipboard";
10
+ import { resolveTuiPort } from "./tui-port";
11
+
12
+ function currentSessionID(api: TuiPluginApi): string | undefined {
13
+ const route = api.route.current;
14
+ if (route.name !== "session") return undefined;
15
+ return (route.params as { sessionID?: string } | undefined)?.sessionID;
16
+ }
17
+
18
+ function copyWithToast(api: TuiPluginApi, text: string, successMessage: string) {
19
+ if (copyToClipboard(text, api.renderer)) {
20
+ api.ui.toast({ message: successMessage, variant: "success" });
21
+ } else {
22
+ api.ui.toast({ message: `Failed: ${successMessage}`, variant: "error" });
23
+ }
24
+ }
25
+
26
+ type ClientWithConfig = TuiPluginApi["client"] & {
27
+ client: { getConfig(): { baseUrl?: string } };
28
+ };
29
+
30
+ function baseUrl(api: TuiPluginApi): string | undefined {
31
+ const url = (api.client as ClientWithConfig).client.getConfig().baseUrl;
32
+ return url;
33
+ }
34
+
35
+ function ClickableRow(props: {
36
+ text: string;
37
+ onCopy: () => void;
38
+ mutedColor: TuiPluginApi["theme"]["current"]["textMuted"];
39
+ textColor: TuiPluginApi["theme"]["current"]["text"];
40
+ }) {
41
+ const [hover, setHover] = createSignal(false);
42
+ return (
43
+ // biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box supports mouse events.
44
+ // biome-ignore lint/a11y/useKeyWithMouseEvents: This row is mouse-only in the TUI sidebar.
45
+ <box
46
+ flexDirection="row"
47
+ onMouseOver={() => setHover(true)}
48
+ onMouseOut={() => setHover(false)}
49
+ onMouseDown={() => props.onCopy()}
50
+ >
51
+ <text fg={hover() ? props.textColor : props.mutedColor} wrapMode="none">
52
+ {props.text}
53
+ </text>
54
+ </box>
55
+ );
56
+ }
57
+
58
+ function EnvoySidebar(props: { api: TuiPluginApi; sessionID: string }) {
59
+ const theme = () => props.api.theme.current;
60
+ const port = resolveTuiPort(baseUrl(props.api), props.sessionID);
61
+
62
+ return (
63
+ <box>
64
+ <ClickableRow
65
+ text={props.sessionID}
66
+ mutedColor={theme().textMuted}
67
+ textColor={theme().text}
68
+ onCopy={() => copyWithToast(props.api, props.sessionID, "Session ID copied")}
69
+ />
70
+ {port !== null ? (
71
+ <ClickableRow
72
+ text={`port ${port}`}
73
+ mutedColor={theme().textMuted}
74
+ textColor={theme().text}
75
+ onCopy={() => copyWithToast(props.api, String(port), "Port copied")}
76
+ />
77
+ ) : null}
78
+ </box>
79
+ );
80
+ }
81
+
82
+ const tui: TuiPlugin = async (api) => {
83
+ // Slash command
84
+ api.keymap.registerLayer({
85
+ commands: [
86
+ {
87
+ name: "envoy.whoami.copy",
88
+ title: "Copy session ID",
89
+ category: "Envoy",
90
+ namespace: "palette",
91
+ slashName: "whoami",
92
+ run() {
93
+ const sessionID = currentSessionID(api);
94
+ if (!sessionID) {
95
+ api.ui.toast({ message: "No active session", variant: "warning" });
96
+ return;
97
+ }
98
+ copyWithToast(api, sessionID, "Session ID copied");
99
+ },
100
+ },
101
+ ],
102
+ });
103
+
104
+ // Sidebar: clickable session ID row
105
+ const slot: TuiSlotPlugin = {
106
+ order: 10,
107
+ slots: {
108
+ sidebar_content(_ctx, value) {
109
+ if (!value.session_id) return null;
110
+ return <EnvoySidebar api={api} sessionID={value.session_id} />;
111
+ },
112
+ },
113
+ };
114
+ api.slots.register(slot);
115
+ };
116
+
117
+ const plugin: TuiPluginModule = {
118
+ id: "envoy-tui",
119
+ tui,
120
+ };
121
+
122
+ export default plugin;
package/tsconfig.json CHANGED
@@ -5,7 +5,9 @@
5
5
  "moduleResolution": "Bundler",
6
6
  "strict": true,
7
7
  "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "jsx": "preserve",
8
10
  "types": ["bun"]
9
11
  },
10
- "include": ["src/**/*.ts"]
12
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
11
13
  }