@sjawhar/opencode-legion-envoy 0.1.6 → 0.1.8

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 CHANGED
@@ -12,7 +12,7 @@ It is the user-facing bridge between OpenCode sessions and Envoy transport.
12
12
 
13
13
  | Task | Location | Notes |
14
14
  | ------------------- | ---------------------- | ------------------------------------------------------------------ |
15
- | Tool definitions | `src/index.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send` |
15
+ | Tool definitions | `src/index.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send`, `envoy_publish`, `envoy_role_set`, `envoy_whoami`, `envoy_sessions` |
16
16
  | Packaging metadata | `package.json` | npm identity, build scripts |
17
17
  | Distribution output | `dist/index.js` | built plugin consumed by OpenCode |
18
18
  | Host rollout helper | `scripts/sync-host.sh` | sync dist + shim to remote host |
package/dist/index.js CHANGED
@@ -14,9 +14,6 @@ var __export = (target, all) => {
14
14
  });
15
15
  };
16
16
 
17
- // src/index.ts
18
- import { readFileSync, unlinkSync, writeFileSync } from "fs";
19
-
20
17
  // ../../node_modules/.bun/zod@4.1.8/node_modules/zod/v4/classic/external.js
21
18
  var exports_external = {};
22
19
  __export(exports_external, {
@@ -12332,7 +12329,7 @@ function date4(params) {
12332
12329
 
12333
12330
  // ../../node_modules/.bun/zod@4.1.8/node_modules/zod/v4/classic/external.js
12334
12331
  config(en_default());
12335
- // ../../node_modules/.bun/@opencode-ai+plugin@1.2.27/node_modules/@opencode-ai/plugin/dist/tool.js
12332
+ // ../../node_modules/.bun/@opencode-ai+plugin@1.4.3/node_modules/@opencode-ai/plugin/dist/tool.js
12336
12333
  function tool(input) {
12337
12334
  return input;
12338
12335
  }
@@ -12385,28 +12382,9 @@ async function call(path, init) {
12385
12382
  return text;
12386
12383
  }
12387
12384
  var src_default = async (input) => {
12388
- const registryDir = process.env.OC_REGISTRY;
12389
12385
  let activeSessionID = null;
12390
- let _activeFile = null;
12386
+ let activeSessionTitle = null;
12391
12387
  let resolvedPort = null;
12392
- const registryFile = (sessionID) => registryDir ? `${registryDir}/${sessionID}.json` : null;
12393
- const update = (sessionID, patch) => {
12394
- const file2 = registryFile(sessionID);
12395
- if (!file2)
12396
- return;
12397
- try {
12398
- let data = {};
12399
- try {
12400
- data = JSON.parse(readFileSync(file2, "utf-8"));
12401
- } catch {
12402
- data = { pid: process.pid, dir: process.cwd(), started: new Date().toISOString() };
12403
- }
12404
- Object.assign(data, patch);
12405
- writeFileSync(file2, `${JSON.stringify(data)}
12406
- `);
12407
- registryFiles.add(file2);
12408
- } catch {}
12409
- };
12410
12388
  let portWarningLogged = false;
12411
12389
  const refreshPort = async () => {
12412
12390
  const port = await resolvePort(input.serverUrl);
@@ -12421,78 +12399,71 @@ var src_default = async (input) => {
12421
12399
  return port;
12422
12400
  };
12423
12401
  const currentPort = () => resolvedPort;
12424
- const syncPort = async (sessionID) => {
12402
+ const syncPort = async () => {
12425
12403
  const value = await refreshPort();
12426
- if (!value)
12427
- return false;
12428
- if (sessionID)
12429
- update(sessionID, { port: value });
12430
- return true;
12404
+ return value !== null;
12431
12405
  };
12432
- const fetchSession = async (sessionID) => {
12406
+ const fetchTitle = async (sessionID) => {
12433
12407
  try {
12434
- const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
12408
+ const res = await fetch(`${input.serverUrl.href}session/${sessionID}`, {
12435
12409
  signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
12436
12410
  });
12437
12411
  if (!res.ok)
12438
12412
  return null;
12439
- const session = await res.json();
12440
- return { id: session.id, title: session.title || "" };
12413
+ const data = await res.json();
12414
+ return data.title ?? null;
12441
12415
  } catch {
12442
12416
  return null;
12443
12417
  }
12444
12418
  };
12445
- const registryFiles = new Set;
12446
- if (registryDir) {
12447
- const timer = setInterval(() => {
12448
- syncPort(activeSessionID ?? undefined).then((resolved) => {
12449
- if (resolved)
12450
- clearInterval(timer);
12451
- });
12452
- }, 1000);
12453
- timer.unref();
12454
- syncPort().catch(() => {});
12455
- const heartbeatInterval = setInterval(() => {
12456
- if (!activeSessionID)
12457
- return;
12458
- const port = currentPort();
12459
- if (!port)
12460
- return;
12461
- call("/v1/interests/subscribe", {
12462
- method: "POST",
12463
- headers: { "Content-Type": "application/json" },
12464
- body: JSON.stringify({
12465
- session_id: activeSessionID,
12466
- dir: process.cwd(),
12467
- topics: [`notifications.agent.${activeSessionID}`],
12468
- port
12469
- })
12470
- }).catch(() => {});
12471
- }, 2 * 60 * 1000);
12472
- process.on("exit", () => {
12473
- clearInterval(timer);
12474
- clearInterval(heartbeatInterval);
12475
- for (const f of registryFiles) {
12476
- try {
12477
- unlinkSync(f);
12478
- } catch {}
12479
- }
12419
+ const timer = setInterval(() => {
12420
+ syncPort().then((resolved) => {
12421
+ if (resolved)
12422
+ clearInterval(timer);
12480
12423
  });
12481
- }
12424
+ }, 1000);
12425
+ timer.unref();
12426
+ syncPort().catch(() => {});
12427
+ const heartbeatInterval = setInterval(() => {
12428
+ if (!activeSessionID)
12429
+ return;
12430
+ const port = currentPort();
12431
+ if (!port)
12432
+ return;
12433
+ call("/v1/interests/subscribe", {
12434
+ method: "POST",
12435
+ headers: { "Content-Type": "application/json" },
12436
+ body: JSON.stringify({
12437
+ session_id: activeSessionID,
12438
+ dir: process.cwd(),
12439
+ topics: [`notifications.agent.${activeSessionID}`],
12440
+ port,
12441
+ title: activeSessionTitle ?? ""
12442
+ })
12443
+ }).catch(() => {});
12444
+ }, 2 * 60 * 1000);
12445
+ process.on("exit", () => {
12446
+ clearInterval(timer);
12447
+ clearInterval(heartbeatInterval);
12448
+ });
12482
12449
  return {
12483
- event: registryDir ? async ({ event }) => {
12450
+ event: async ({
12451
+ event
12452
+ }) => {
12484
12453
  if (activeSessionID)
12485
- syncPort(activeSessionID).catch(() => {});
12454
+ syncPort().catch(() => {});
12486
12455
  if (event.type === "session.status" && event.properties?.status?.type === "busy") {
12487
12456
  const sessionID = event.properties?.sessionID;
12488
12457
  if (sessionID && sessionID !== activeSessionID) {
12489
12458
  activeSessionID = sessionID;
12490
- _activeFile = registryFile(sessionID);
12491
- const session = await fetchSession(sessionID);
12492
- if (session)
12493
- update(sessionID, { session });
12494
- await syncPort(sessionID);
12459
+ activeSessionTitle = null;
12460
+ await syncPort();
12495
12461
  const port = currentPort();
12462
+ const titlePromise = fetchTitle(sessionID).then((t) => {
12463
+ if (t && activeSessionID === sessionID)
12464
+ activeSessionTitle = t;
12465
+ return t;
12466
+ });
12496
12467
  if (port) {
12497
12468
  call("/v1/interests/subscribe", {
12498
12469
  method: "POST",
@@ -12501,27 +12472,29 @@ var src_default = async (input) => {
12501
12472
  session_id: sessionID,
12502
12473
  dir: process.cwd(),
12503
12474
  topics: [`notifications.agent.${sessionID}`],
12504
- port
12475
+ port,
12476
+ title: activeSessionTitle ?? ""
12505
12477
  })
12506
12478
  }).catch(() => {});
12479
+ titlePromise.then((title) => {
12480
+ if (title && activeSessionID === sessionID) {
12481
+ call("/v1/interests/subscribe", {
12482
+ method: "POST",
12483
+ headers: { "Content-Type": "application/json" },
12484
+ body: JSON.stringify({
12485
+ session_id: sessionID,
12486
+ dir: process.cwd(),
12487
+ topics: [`notifications.agent.${sessionID}`],
12488
+ port: currentPort() ?? 0,
12489
+ title
12490
+ })
12491
+ }).catch(() => {});
12492
+ }
12493
+ });
12507
12494
  }
12508
12495
  }
12509
12496
  }
12510
- if (event.type === "session.updated") {
12511
- const info = event.properties?.info;
12512
- if (info && info.id === activeSessionID) {
12513
- update(info.id, { session: { id: info.id, title: info.title || "" } });
12514
- }
12515
- }
12516
- if (event.type === "session.idle") {
12517
- const sessionID = event.properties?.sessionID;
12518
- if (sessionID && sessionID === activeSessionID) {
12519
- const session = await fetchSession(sessionID);
12520
- if (session)
12521
- update(sessionID, { session });
12522
- }
12523
- }
12524
- } : undefined,
12497
+ },
12525
12498
  tool: {
12526
12499
  envoy_subscribe: tool({
12527
12500
  description: "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.",
@@ -12537,7 +12510,8 @@ var src_default = async (input) => {
12537
12510
  session_id: ctx.sessionID,
12538
12511
  dir: ctx.directory,
12539
12512
  topics: args.topics,
12540
- port: currentPort() ?? 0
12513
+ port: currentPort() ?? 0,
12514
+ title: activeSessionTitle ?? ""
12541
12515
  })
12542
12516
  });
12543
12517
  }
@@ -12621,6 +12595,35 @@ var src_default = async (input) => {
12621
12595
  })
12622
12596
  });
12623
12597
  }
12598
+ }),
12599
+ envoy_whoami: tool({
12600
+ description: "Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
12601
+ args: {},
12602
+ async execute(_args, ctx) {
12603
+ ctx.metadata({ title: "Envoy whoami" });
12604
+ const sessionID = ctx.sessionID;
12605
+ const port = currentPort();
12606
+ return JSON.stringify({
12607
+ session_id: sessionID,
12608
+ machine_id: process.env.HOSTNAME || "unknown",
12609
+ port,
12610
+ dir: ctx.directory
12611
+ }, null, 2);
12612
+ }
12613
+ }),
12614
+ envoy_sessions: tool({
12615
+ description: "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.",
12616
+ args: {
12617
+ machine: tool.schema.string().optional().describe("Filter to sessions on this machine ID (e.g. hostname). Omit to list all machines.")
12618
+ },
12619
+ async execute(args, ctx) {
12620
+ ctx.metadata({ title: "Envoy sessions" });
12621
+ const res = await call("/v1/sessions");
12622
+ if (!args.machine)
12623
+ return res;
12624
+ const sessions = JSON.parse(res);
12625
+ return JSON.stringify(sessions.filter((s) => s.machine_id === args.machine), null, 2);
12626
+ }
12624
12627
  })
12625
12628
  }
12626
12629
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -13,9 +13,7 @@ describe("envoy plugin init", () => {
13
13
  it("returns immediately without blocking on port resolution or Envoy calls", async () => {
14
14
  // Simulate NATS/Envoy being unavailable — plugin init must still complete fast
15
15
  const originalEnvoyUrl = process.env.ENVOY_URL;
16
- const originalRegistry = process.env.OC_REGISTRY;
17
16
  process.env.ENVOY_URL = "http://127.0.0.1:59999"; // Non-existent
18
- process.env.OC_REGISTRY = undefined;
19
17
 
20
18
  try {
21
19
  const pluginModule = await import("../index");
@@ -33,9 +31,10 @@ describe("envoy plugin init", () => {
33
31
  expect(hooks.tool.envoy_list).toBeDefined();
34
32
  expect(hooks.tool.envoy_send).toBeDefined();
35
33
  expect(hooks.tool.envoy_publish).toBeDefined();
34
+ expect(hooks.tool.envoy_whoami).toBeDefined();
35
+ expect(hooks.tool.envoy_sessions).toBeDefined();
36
36
  } finally {
37
37
  process.env.ENVOY_URL = originalEnvoyUrl;
38
- process.env.OC_REGISTRY = originalRegistry;
39
38
  }
40
39
  });
41
40
 
@@ -43,9 +42,7 @@ describe("envoy plugin init", () => {
43
42
  // The call function has AbortSignal.timeout — verify it doesn't hang
44
43
  // We test this indirectly: a tool call to non-existent Envoy should reject within timeout
45
44
  const originalEnvoyUrl = process.env.ENVOY_URL;
46
- const originalRegistry = process.env.OC_REGISTRY;
47
45
  process.env.ENVOY_URL = "http://127.0.0.1:59999";
48
- process.env.OC_REGISTRY = undefined;
49
46
 
50
47
  try {
51
48
  const pluginModule = await import("../index");
@@ -68,7 +65,168 @@ describe("envoy plugin init", () => {
68
65
  expect(elapsed).toBeLessThan(6000);
69
66
  } finally {
70
67
  process.env.ENVOY_URL = originalEnvoyUrl;
71
- process.env.OC_REGISTRY = originalRegistry;
68
+ }
69
+ });
70
+ });
71
+
72
+ describe("envoy_whoami", () => {
73
+ it("returns session identity when Envoy is unavailable", async () => {
74
+ const originalEnvoyUrl = process.env.ENVOY_URL;
75
+ const originalHostname = process.env.HOSTNAME;
76
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
77
+ process.env.HOSTNAME = "test-machine";
78
+
79
+ try {
80
+ const pluginModule = await import("../index");
81
+ const initPlugin = pluginModule.default;
82
+ const hooks = await initPlugin({
83
+ serverUrl: new URL("http://127.0.0.1:13381"),
84
+ } as never);
85
+
86
+ const result = await hooks.tool.envoy_whoami.execute({}, {
87
+ sessionID: "ses_test_whoami",
88
+ directory: "/tmp/test-workspace",
89
+ metadata: mock(() => {}),
90
+ } as never);
91
+
92
+ const parsed = JSON.parse(result);
93
+ expect(parsed.session_id).toBe("ses_test_whoami");
94
+ expect(parsed.machine_id).toBe("test-machine");
95
+ expect(parsed.dir).toBe("/tmp/test-workspace");
96
+ expect(parsed).not.toHaveProperty("topics");
97
+ expect(parsed.port === null || typeof parsed.port === "number").toBe(true);
98
+ } finally {
99
+ process.env.ENVOY_URL = originalEnvoyUrl;
100
+ if (originalHostname === undefined) {
101
+ delete process.env.HOSTNAME;
102
+ } else {
103
+ process.env.HOSTNAME = originalHostname;
104
+ }
105
+ }
106
+ });
107
+
108
+ it("uses 'unknown' for machine_id when HOSTNAME is not set", async () => {
109
+ const originalEnvoyUrl = process.env.ENVOY_URL;
110
+ const originalHostname = process.env.HOSTNAME;
111
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
112
+ delete process.env.HOSTNAME;
113
+
114
+ try {
115
+ const pluginModule = await import("../index");
116
+ const initPlugin = pluginModule.default;
117
+ const hooks = await initPlugin({
118
+ serverUrl: new URL("http://127.0.0.1:13381"),
119
+ } as never);
120
+
121
+ const result = await hooks.tool.envoy_whoami.execute({}, {
122
+ sessionID: "ses_no_hostname",
123
+ directory: "/tmp",
124
+ metadata: mock(() => {}),
125
+ } as never);
126
+
127
+ const parsed = JSON.parse(result);
128
+ expect(parsed.machine_id).toBe("unknown");
129
+ } finally {
130
+ process.env.ENVOY_URL = originalEnvoyUrl;
131
+ if (originalHostname === undefined) {
132
+ delete process.env.HOSTNAME;
133
+ } else {
134
+ process.env.HOSTNAME = originalHostname;
135
+ }
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("envoy_sessions", () => {
141
+ it("rejects with error when Envoy is unavailable", async () => {
142
+ const originalEnvoyUrl = process.env.ENVOY_URL;
143
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
144
+
145
+ try {
146
+ const pluginModule = await import("../index");
147
+ const initPlugin = pluginModule.default;
148
+ const hooks = await initPlugin({
149
+ serverUrl: new URL("http://127.0.0.1:13381"),
150
+ } as never);
151
+
152
+ await expect(
153
+ hooks.tool.envoy_sessions.execute({}, {
154
+ sessionID: "ses_test",
155
+ directory: "/tmp",
156
+ metadata: mock(() => {}),
157
+ } as never)
158
+ ).rejects.toThrow();
159
+ } finally {
160
+ process.env.ENVOY_URL = originalEnvoyUrl;
161
+ }
162
+ });
163
+ });
164
+
165
+ describe("session title", () => {
166
+ it("includes title in follow-up subscribe after session activation", async () => {
167
+ const originalEnvoyUrl = process.env.ENVOY_URL;
168
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
169
+
170
+ const fetchCalls: { url: string; body?: string }[] = [];
171
+ const originalFetch = globalThis.fetch;
172
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
173
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
174
+ if (init?.body) {
175
+ fetchCalls.push({ url, body: init.body as string });
176
+ } else {
177
+ fetchCalls.push({ url });
178
+ }
179
+ // Serve API: return session with title
180
+ if (url.includes("/session/ses_title_test")) {
181
+ return new Response(JSON.stringify({ id: "ses_title_test", title: "Test Title" }), {
182
+ status: 200,
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ }
186
+ // Envoy subscribe calls: return success
187
+ if (url.includes("/v1/interests/subscribe")) {
188
+ return new Response(JSON.stringify({ session_id: "ses_title_test", topics: [] }), {
189
+ status: 200,
190
+ headers: { "Content-Type": "application/json" },
191
+ });
192
+ }
193
+ // Port resolution calls
194
+ if (url.includes("/session") && !url.includes("ses_title_test")) {
195
+ return new Response("not found", { status: 404 });
196
+ }
197
+ throw new Error("connection refused");
198
+ }) as typeof fetch;
199
+
200
+ try {
201
+ const pluginModule = await import("../index");
202
+ const hooks = await pluginModule.default({
203
+ serverUrl: new URL("http://127.0.0.1:13381/"),
204
+ } as never);
205
+
206
+ await hooks.event({
207
+ event: {
208
+ type: "session.status",
209
+ properties: {
210
+ sessionID: "ses_title_test",
211
+ status: { type: "busy" },
212
+ },
213
+ },
214
+ });
215
+
216
+ // Allow async title fetch and follow-up subscribe to complete
217
+ await new Promise((r) => setTimeout(r, 500));
218
+
219
+ const subscribeCalls = fetchCalls.filter(
220
+ (c) => c.url.includes("/v1/interests/subscribe") && c.body
221
+ );
222
+ const hasTitle = subscribeCalls.some((c) => {
223
+ const body = JSON.parse(c.body as string);
224
+ return body.title === "Test Title";
225
+ });
226
+ expect(hasTitle).toBe(true);
227
+ } finally {
228
+ globalThis.fetch = originalFetch;
229
+ process.env.ENVOY_URL = originalEnvoyUrl;
72
230
  }
73
231
  });
74
232
  });
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { tool } from "@opencode-ai/plugin/tool";
3
2
  import { resolvePort } from "./port";
4
3
 
@@ -18,31 +17,11 @@ async function call(path: string, init?: RequestInit) {
18
17
  }
19
18
 
20
19
  export default async (input: { serverUrl: URL }) => {
21
- const registryDir = process.env.OC_REGISTRY;
22
20
  let activeSessionID: string | null = null;
23
- let _activeFile: string | null = null;
21
+ let activeSessionTitle: string | null = null;
24
22
  /** Cached port — resolved asynchronously, null until first successful resolution. */
25
23
  let resolvedPort: number | null = null;
26
24
 
27
- const registryFile = (sessionID: string) =>
28
- registryDir ? `${registryDir}/${sessionID}.json` : null;
29
-
30
- const update = (sessionID: string, patch: Record<string, unknown>) => {
31
- const file = registryFile(sessionID);
32
- if (!file) return;
33
- try {
34
- let data: Record<string, unknown> = {};
35
- try {
36
- data = JSON.parse(readFileSync(file, "utf-8"));
37
- } catch {
38
- data = { pid: process.pid, dir: process.cwd(), started: new Date().toISOString() };
39
- }
40
- Object.assign(data, patch);
41
- writeFileSync(file, `${JSON.stringify(data)}\n`);
42
- registryFiles.add(file);
43
- } catch {}
44
- };
45
-
46
25
  let portWarningLogged = false;
47
26
  const refreshPort = async (): Promise<number | null> => {
48
27
  const port = await resolvePort(input.serverUrl);
@@ -62,90 +41,101 @@ export default async (input: { serverUrl: URL }) => {
62
41
  /** Return cached port synchronously — tools need a port value inline. */
63
42
  const currentPort = () => resolvedPort;
64
43
 
65
- const syncPort = async (sessionID?: string): Promise<boolean> => {
44
+ const syncPort = async (): Promise<boolean> => {
66
45
  const value = await refreshPort();
67
- if (!value) return false;
68
- if (sessionID) update(sessionID, { port: value });
69
- return true;
46
+ return value !== null;
70
47
  };
71
48
 
72
- const fetchSession = async (sessionID: string) => {
49
+ /** Fetch session title from the OpenCode serve API. Best-effort — returns null on failure. */
50
+ const fetchTitle = async (sessionID: string): Promise<string | null> => {
73
51
  try {
74
- const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
52
+ const res = await fetch(`${input.serverUrl.href}session/${sessionID}`, {
75
53
  signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
76
54
  });
77
55
  if (!res.ok) return null;
78
- const session = await res.json();
79
- return { id: session.id, title: session.title || "" };
56
+ const data = (await res.json()) as { title?: string };
57
+ return data.title ?? null;
80
58
  } catch {
81
59
  return null;
82
60
  }
83
61
  };
84
62
 
85
- // Track all registry files this process created, clean up on exit
86
- const registryFiles = new Set<string>();
87
-
88
- if (registryDir) {
89
- // Defer port resolution to background — never block plugin init.
90
- // The port sync loop retries every second until resolved.
91
- const timer = setInterval(() => {
92
- syncPort(activeSessionID ?? undefined).then((resolved) => {
93
- if (resolved) clearInterval(timer);
94
- });
95
- }, 1000);
96
- timer.unref(); // Don't prevent graceful shutdown if port never resolves
97
- // Fire an immediate async attempt (non-blocking)
98
- syncPort().catch(() => {});
63
+ // Defer port resolution to background never block plugin init.
64
+ // The port sync loop retries every second until resolved.
65
+ const timer = setInterval(() => {
66
+ syncPort().then((resolved) => {
67
+ if (resolved) clearInterval(timer);
68
+ });
69
+ }, 1000);
70
+ timer.unref(); // Don't prevent graceful shutdown if port never resolves
71
+ // Fire an immediate async attempt (non-blocking)
72
+ syncPort().catch(() => {});
99
73
 
100
- // Heartbeat: re-subscribe every 2 minutes to refresh envoy_sessions TTL (5-min)
101
- const heartbeatInterval = setInterval(
102
- () => {
103
- if (!activeSessionID) return;
104
- const port = currentPort();
105
- if (!port) return;
106
- call("/v1/interests/subscribe", {
107
- method: "POST",
108
- headers: { "Content-Type": "application/json" },
109
- body: JSON.stringify({
110
- session_id: activeSessionID,
111
- dir: process.cwd(),
112
- topics: [`notifications.agent.${activeSessionID}`],
113
- port,
114
- }),
115
- }).catch(() => {});
116
- },
117
- 2 * 60 * 1000
118
- );
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
+ );
119
94
 
120
- process.on("exit", () => {
121
- clearInterval(timer);
122
- clearInterval(heartbeatInterval);
123
- for (const f of registryFiles) {
124
- try {
125
- unlinkSync(f);
126
- } catch {}
127
- }
128
- });
129
- }
95
+ process.on("exit", () => {
96
+ clearInterval(timer);
97
+ clearInterval(heartbeatInterval);
98
+ });
130
99
 
131
100
  return {
132
- event: registryDir
133
- ? async ({ event }: { event: { type?: string; properties?: Record<string, unknown> } }) => {
134
- if (activeSessionID) syncPort(activeSessionID).catch(() => {});
101
+ event: async ({
102
+ event,
103
+ }: {
104
+ event: { type?: string; properties?: Record<string, unknown> };
105
+ }) => {
106
+ if (activeSessionID) syncPort().catch(() => {});
135
107
 
136
- if (
137
- event.type === "session.status" &&
138
- (event.properties?.status as { type?: string } | undefined)?.type === "busy"
139
- ) {
140
- const sessionID = event.properties?.sessionID as string | undefined;
141
- if (sessionID && sessionID !== activeSessionID) {
142
- activeSessionID = sessionID;
143
- _activeFile = registryFile(sessionID);
144
- const session = await fetchSession(sessionID);
145
- if (session) update(sessionID, { session });
146
- await syncPort(sessionID);
147
- const port = currentPort();
148
- if (port) {
108
+ if (
109
+ event.type === "session.status" &&
110
+ (event.properties?.status as { type?: string } | undefined)?.type === "busy"
111
+ ) {
112
+ const sessionID = event.properties?.sessionID as string | undefined;
113
+ if (sessionID && sessionID !== activeSessionID) {
114
+ activeSessionID = sessionID;
115
+ activeSessionTitle = null;
116
+ await syncPort();
117
+ const port = currentPort();
118
+ // Fetch title — best-effort, non-blocking for initial subscribe
119
+ const titlePromise = fetchTitle(sessionID).then((t) => {
120
+ // Guard: only update if this session is still active (prevents race on fast switches)
121
+ if (t && activeSessionID === sessionID) activeSessionTitle = t;
122
+ return t;
123
+ });
124
+ 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
137
+ titlePromise.then((title) => {
138
+ if (title && activeSessionID === sessionID) {
149
139
  call("/v1/interests/subscribe", {
150
140
  method: "POST",
151
141
  headers: { "Content-Type": "application/json" },
@@ -153,28 +143,16 @@ export default async (input: { serverUrl: URL }) => {
153
143
  session_id: sessionID,
154
144
  dir: process.cwd(),
155
145
  topics: [`notifications.agent.${sessionID}`],
156
- port,
146
+ port: currentPort() ?? 0,
147
+ title,
157
148
  }),
158
149
  }).catch(() => {});
159
150
  }
160
- }
161
- }
162
- if (event.type === "session.updated") {
163
- const info = event.properties?.info as { id?: string; title?: string } | undefined;
164
- if (info && info.id === activeSessionID) {
165
- update(info.id, { session: { id: info.id, title: info.title || "" } });
166
- }
167
- }
168
-
169
- if (event.type === "session.idle") {
170
- const sessionID = event.properties?.sessionID as string | undefined;
171
- if (sessionID && sessionID === activeSessionID) {
172
- const session = await fetchSession(sessionID);
173
- if (session) update(sessionID, { session });
174
- }
151
+ });
175
152
  }
176
153
  }
177
- : undefined,
154
+ }
155
+ },
178
156
  tool: {
179
157
  envoy_subscribe: tool({
180
158
  description:
@@ -196,6 +174,7 @@ export default async (input: { serverUrl: URL }) => {
196
174
  dir: ctx.directory,
197
175
  topics: args.topics,
198
176
  port: currentPort() ?? 0,
177
+ title: activeSessionTitle ?? "",
199
178
  }),
200
179
  });
201
180
  },
@@ -298,6 +277,51 @@ export default async (input: { serverUrl: URL }) => {
298
277
  });
299
278
  },
300
279
  }),
280
+ envoy_whoami: tool({
281
+ description:
282
+ "Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
283
+ args: {},
284
+ async execute(_args, ctx) {
285
+ ctx.metadata({ title: "Envoy whoami" });
286
+ const sessionID = ctx.sessionID;
287
+ const port = currentPort();
288
+ return JSON.stringify(
289
+ {
290
+ session_id: sessionID,
291
+ machine_id: process.env.HOSTNAME || "unknown",
292
+ port,
293
+ dir: ctx.directory,
294
+ },
295
+ null,
296
+ 2
297
+ );
298
+ },
299
+ }),
300
+ envoy_sessions: tool({
301
+ description:
302
+ "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.",
303
+ args: {
304
+ machine: tool.schema
305
+ .string()
306
+ .optional()
307
+ .describe(
308
+ "Filter to sessions on this machine ID (e.g. hostname). Omit to list all machines."
309
+ ),
310
+ },
311
+ async execute(args, ctx) {
312
+ ctx.metadata({ title: "Envoy sessions" });
313
+ const res = await call("/v1/sessions");
314
+ if (!args.machine) return res;
315
+ const sessions = JSON.parse(res) as Array<{
316
+ machine_id: string;
317
+ }>;
318
+ return JSON.stringify(
319
+ sessions.filter((s) => s.machine_id === args.machine),
320
+ null,
321
+ 2
322
+ );
323
+ },
324
+ }),
301
325
  },
302
326
  };
303
327
  };