@sjawhar/opencode-legion-envoy 0.1.1 → 0.1.2

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/dist/index.js CHANGED
@@ -12339,13 +12339,21 @@ function tool(input) {
12339
12339
  tool.schema = exports_external;
12340
12340
 
12341
12341
  // src/port.ts
12342
- import { execFileSync } from "child_process";
12343
- function resolvePort(serverUrl, exec = execFileSync) {
12342
+ import { execFile } from "child_process";
12343
+ var defaultExec = (command, args, options) => new Promise((resolve, reject) => {
12344
+ execFile(command, args, { encoding: options.encoding }, (error45, stdout) => {
12345
+ if (error45)
12346
+ reject(error45);
12347
+ else
12348
+ resolve(stdout);
12349
+ });
12350
+ });
12351
+ async function resolvePort(serverUrl, exec = defaultExec) {
12344
12352
  const urlPort = Number.parseInt(serverUrl.port, 10);
12345
12353
  if (Number.isFinite(urlPort) && urlPort > 0)
12346
12354
  return urlPort;
12347
12355
  try {
12348
- const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
12356
+ const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
12349
12357
  for (const line of output.split(`
12350
12358
  `)) {
12351
12359
  if (!line.includes(`pid=${process.pid}`))
@@ -12365,8 +12373,12 @@ function resolvePort(serverUrl, exec = execFileSync) {
12365
12373
 
12366
12374
  // src/index.ts
12367
12375
  var root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
12376
+ var CALL_TIMEOUT_MS = 5000;
12368
12377
  async function call(path, init) {
12369
- const res = await fetch(`${root}${path}`, init);
12378
+ const res = await fetch(`${root}${path}`, {
12379
+ ...init,
12380
+ signal: init?.signal ?? AbortSignal.timeout(CALL_TIMEOUT_MS)
12381
+ });
12370
12382
  const text = await res.text();
12371
12383
  if (!res.ok)
12372
12384
  throw new Error(text || `${res.status}`);
@@ -12376,6 +12388,7 @@ var src_default = async (input) => {
12376
12388
  const registryDir = process.env.OC_REGISTRY;
12377
12389
  let activeSessionID = null;
12378
12390
  let _activeFile = null;
12391
+ let resolvedPort = null;
12379
12392
  const registryFile = (sessionID) => registryDir ? `${registryDir}/${sessionID}.json` : null;
12380
12393
  const update = (sessionID, patch) => {
12381
12394
  const file2 = registryFile(sessionID);
@@ -12395,18 +12408,21 @@ var src_default = async (input) => {
12395
12408
  } catch {}
12396
12409
  };
12397
12410
  let portWarningLogged = false;
12398
- const currentPort = () => {
12399
- const port = resolvePort(input.serverUrl);
12411
+ const refreshPort = async () => {
12412
+ const port = await resolvePort(input.serverUrl);
12400
12413
  if (!port && !portWarningLogged) {
12401
12414
  portWarningLogged = true;
12402
12415
  console.error(`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`);
12403
12416
  }
12404
- if (port)
12417
+ if (port) {
12405
12418
  portWarningLogged = false;
12419
+ resolvedPort = port;
12420
+ }
12406
12421
  return port;
12407
12422
  };
12408
- const syncPort = (sessionID) => {
12409
- const value = currentPort();
12423
+ const currentPort = () => resolvedPort;
12424
+ const syncPort = async (sessionID) => {
12425
+ const value = await refreshPort();
12410
12426
  if (!value)
12411
12427
  return false;
12412
12428
  if (sessionID)
@@ -12415,7 +12431,9 @@ var src_default = async (input) => {
12415
12431
  };
12416
12432
  const fetchSession = async (sessionID) => {
12417
12433
  try {
12418
- const res = await fetch(`${input.serverUrl}/session/${sessionID}`);
12434
+ const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
12435
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
12436
+ });
12419
12437
  if (!res.ok)
12420
12438
  return null;
12421
12439
  const session = await res.json();
@@ -12426,11 +12444,14 @@ var src_default = async (input) => {
12426
12444
  };
12427
12445
  const registryFiles = new Set;
12428
12446
  if (registryDir) {
12429
- syncPort();
12430
12447
  const timer = setInterval(() => {
12431
- if (syncPort(activeSessionID ?? undefined))
12432
- clearInterval(timer);
12448
+ syncPort(activeSessionID ?? undefined).then((resolved) => {
12449
+ if (resolved)
12450
+ clearInterval(timer);
12451
+ });
12433
12452
  }, 1000);
12453
+ timer.unref();
12454
+ syncPort().catch(() => {});
12434
12455
  const heartbeatInterval = setInterval(() => {
12435
12456
  if (!activeSessionID)
12436
12457
  return;
@@ -12449,6 +12470,7 @@ var src_default = async (input) => {
12449
12470
  }).catch(() => {});
12450
12471
  }, 2 * 60 * 1000);
12451
12472
  process.on("exit", () => {
12473
+ clearInterval(timer);
12452
12474
  clearInterval(heartbeatInterval);
12453
12475
  for (const f of registryFiles) {
12454
12476
  try {
@@ -12460,7 +12482,7 @@ var src_default = async (input) => {
12460
12482
  return {
12461
12483
  event: registryDir ? async ({ event }) => {
12462
12484
  if (activeSessionID)
12463
- syncPort(activeSessionID);
12485
+ syncPort(activeSessionID).catch(() => {});
12464
12486
  if (event.type === "session.status" && event.properties?.status?.type === "busy") {
12465
12487
  const sessionID = event.properties?.sessionID;
12466
12488
  if (sessionID && sessionID !== activeSessionID) {
@@ -12469,7 +12491,7 @@ var src_default = async (input) => {
12469
12491
  const session = await fetchSession(sessionID);
12470
12492
  if (session)
12471
12493
  update(sessionID, { session });
12472
- syncPort(sessionID);
12494
+ await syncPort(sessionID);
12473
12495
  const port = currentPort();
12474
12496
  if (port) {
12475
12497
  call("/v1/interests/subscribe", {
@@ -12502,9 +12524,9 @@ var src_default = async (input) => {
12502
12524
  } : undefined,
12503
12525
  tool: {
12504
12526
  envoy_subscribe: tool({
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.",
12527
+ 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.",
12506
12528
  args: {
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")
12529
+ topics: tool.schema.array(tool.schema.string()).describe("NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention")
12508
12530
  },
12509
12531
  async execute(args, ctx) {
12510
12532
  ctx.metadata({ title: "Envoy subscribe" });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/sjawhar/legion"
9
+ },
6
10
  "types": "dist/index.d.ts",
7
11
  "scripts": {
8
12
  "build": "bun build src/index.ts --outdir dist --target bun --format esm",
@@ -0,0 +1,74 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ // Suppress console.error during tests
4
+ const originalError = console.error;
5
+ beforeEach(() => {
6
+ console.error = mock(() => {});
7
+ });
8
+ afterEach(() => {
9
+ console.error = originalError;
10
+ });
11
+
12
+ describe("envoy plugin init", () => {
13
+ it("returns immediately without blocking on port resolution or Envoy calls", async () => {
14
+ // Simulate NATS/Envoy being unavailable — plugin init must still complete fast
15
+ const originalEnvoyUrl = process.env.ENVOY_URL;
16
+ const originalRegistry = process.env.OC_REGISTRY;
17
+ process.env.ENVOY_URL = "http://127.0.0.1:59999"; // Non-existent
18
+ process.env.OC_REGISTRY = undefined;
19
+
20
+ try {
21
+ const pluginModule = await import("../index");
22
+ const initPlugin = pluginModule.default;
23
+
24
+ const start = performance.now();
25
+ const hooks = await initPlugin({ serverUrl: new URL("http://127.0.0.1:13381") } as never);
26
+ const elapsed = performance.now() - start;
27
+
28
+ // Plugin init must complete in under 1 second regardless of NATS state
29
+ expect(elapsed).toBeLessThan(1000);
30
+ expect(hooks.tool).toBeDefined();
31
+ expect(hooks.tool.envoy_subscribe).toBeDefined();
32
+ expect(hooks.tool.envoy_unsubscribe).toBeDefined();
33
+ expect(hooks.tool.envoy_list).toBeDefined();
34
+ expect(hooks.tool.envoy_send).toBeDefined();
35
+ expect(hooks.tool.envoy_publish).toBeDefined();
36
+ } finally {
37
+ process.env.ENVOY_URL = originalEnvoyUrl;
38
+ process.env.OC_REGISTRY = originalRegistry;
39
+ }
40
+ });
41
+
42
+ it("call() includes a timeout to prevent hanging on unresponsive Envoy", async () => {
43
+ // The call function has AbortSignal.timeout — verify it doesn't hang
44
+ // We test this indirectly: a tool call to non-existent Envoy should reject within timeout
45
+ const originalEnvoyUrl = process.env.ENVOY_URL;
46
+ const originalRegistry = process.env.OC_REGISTRY;
47
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
48
+ process.env.OC_REGISTRY = undefined;
49
+
50
+ try {
51
+ const pluginModule = await import("../index");
52
+ const initPlugin = pluginModule.default;
53
+ const hooks = await initPlugin({ serverUrl: new URL("http://127.0.0.1:13381") } as never);
54
+
55
+ const start = performance.now();
56
+ try {
57
+ await hooks.tool.envoy_list.execute({}, {
58
+ sessionID: "ses_test",
59
+ directory: "/tmp",
60
+ metadata: () => {},
61
+ } as never);
62
+ } catch {
63
+ // Expected to fail — Envoy is not running
64
+ }
65
+ const elapsed = performance.now() - start;
66
+
67
+ // Should fail fast due to connection refused, not hang indefinitely
68
+ expect(elapsed).toBeLessThan(6000);
69
+ } finally {
70
+ process.env.ENVOY_URL = originalEnvoyUrl;
71
+ process.env.OC_REGISTRY = originalRegistry;
72
+ }
73
+ });
74
+ });
@@ -2,9 +2,9 @@ import { describe, expect, it, mock } from "bun:test";
2
2
  import { resolvePort } from "../port";
3
3
 
4
4
  describe("resolvePort", () => {
5
- const noopExec = (() => {
5
+ const noopExec = (async () => {
6
6
  throw new Error("ss not available");
7
- }) as typeof import("node:child_process").execFileSync;
7
+ }) as Parameters<typeof resolvePort>[1];
8
8
 
9
9
  const ssOutput = (pid: number, port: number) =>
10
10
  [
@@ -14,93 +14,111 @@ describe("resolvePort", () => {
14
14
  ].join("\n");
15
15
 
16
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);
17
+ it("returns port from standard non-default URL", async () => {
18
+ expect(await resolvePort(new URL("http://127.0.0.1:4096"), noopExec)).toBe(4096);
19
19
  });
20
20
 
21
- it("returns port for high serve ports", () => {
22
- expect(resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
21
+ it("returns port for high serve ports", async () => {
22
+ expect(await resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
23
23
  });
24
24
 
25
- it("returns port for localhost URLs", () => {
26
- expect(resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
25
+ it("returns port for localhost URLs", async () => {
26
+ expect(await resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
27
27
  });
28
28
 
29
- it("returns port for IPv6 URLs", () => {
30
- expect(resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
29
+ it("returns port for IPv6 URLs", async () => {
30
+ expect(await resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
31
+ });
32
+
33
+ it("skips exec entirely when URL port is valid", async () => {
34
+ const exec = mock(async () => ssOutput(process.pid, 9999));
35
+ expect(await resolvePort(new URL("http://127.0.0.1:4096"), exec as never)).toBe(4096);
36
+ expect(exec).not.toHaveBeenCalled();
31
37
  });
32
38
  });
33
39
 
34
40
  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);
41
+ it("uses ss when URL has no port (default HTTP)", async () => {
42
+ const exec = mock(async () => ssOutput(process.pid, 4096));
43
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
38
44
  expect(exec).toHaveBeenCalledWith("ss", ["-tlnp"], { encoding: "utf-8" });
39
45
  });
40
46
 
41
- it("uses ss when URL port is 0", () => {
47
+ it("uses ss when URL port is 0", async () => {
42
48
  // 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);
49
+ const exec = mock(async () => ssOutput(process.pid, 13381));
50
+ expect(await resolvePort(new URL("http://127.0.0.1:0"), exec as never)).toBe(13381);
45
51
  });
46
52
 
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();
53
+ it("ignores ss lines with different PIDs", async () => {
54
+ const exec = mock(async () => ssOutput(99999, 4096));
55
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
50
56
  });
51
57
 
52
- it("handles multiple ss entries and picks the matching PID", () => {
58
+ it("handles multiple ss entries and picks the matching PID", async () => {
53
59
  const output = [
54
60
  "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
55
61
  `LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("node",pid=99999,fd=6))`,
56
62
  `LISTEN 0 511 127.0.0.1:4096 0.0.0.0:* users:(("bun",pid=${process.pid},fd=7))`,
57
63
  "",
58
64
  ].join("\n");
59
- const exec = mock(() => output);
60
- expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
65
+ const exec = mock(async () => output);
66
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
61
67
  });
62
68
 
63
- it("handles IPv6 listening addresses in ss output", () => {
69
+ it("handles IPv6 listening addresses in ss output", async () => {
64
70
  const output = [
65
71
  "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
66
72
  `LISTEN 0 511 [::]:4096 [::]:* users:(("bun",pid=${process.pid},fd=6))`,
67
73
  "",
68
74
  ].join("\n");
69
- const exec = mock(() => output);
70
- expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
75
+ const exec = mock(async () => output);
76
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
77
+ });
78
+
79
+ it("works with async exec that resolves after a delay", async () => {
80
+ const exec = mock(
81
+ () =>
82
+ new Promise<string>((resolve) =>
83
+ setTimeout(() => resolve(ssOutput(process.pid, 5555)), 10)
84
+ )
85
+ );
86
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(5555);
71
87
  });
72
88
  });
89
+
73
90
  describe("failure cases", () => {
74
- it("returns null when ss is not available", () => {
75
- expect(resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
91
+ it("returns null when ss is not available", async () => {
92
+ expect(await resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
76
93
  });
77
94
 
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();
95
+ it("returns null when ss returns empty output", async () => {
96
+ const exec = mock(async () => "");
97
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
98
+ });
99
+
100
+ it("returns null when async exec rejects", async () => {
101
+ const exec = mock(async () => {
102
+ throw new Error("command failed");
103
+ });
104
+ expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
81
105
  });
82
106
  });
83
107
 
84
108
  describe("edge cases", () => {
85
- it("URL.port is empty string for default HTTP port 80", () => {
109
+ it("URL.port is empty string for default HTTP port 80", async () => {
86
110
  // http://127.0.0.1:80 → URL.port is "" (80 is default for http)
87
111
  const url = new URL("http://127.0.0.1:80");
88
112
  expect(url.port).toBe("");
89
- const exec = mock(() => ssOutput(process.pid, 4096));
90
- expect(resolvePort(url, exec as never)).toBe(4096);
113
+ const exec = mock(async () => ssOutput(process.pid, 4096));
114
+ expect(await resolvePort(url, exec as never)).toBe(4096);
91
115
  });
92
116
 
93
- it("URL.port is empty string for default HTTPS port 443", () => {
117
+ it("URL.port is empty string for default HTTPS port 443", async () => {
94
118
  const url = new URL("https://127.0.0.1:443");
95
119
  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();
120
+ const exec = mock(async () => ssOutput(process.pid, 4096));
121
+ expect(await resolvePort(url, exec as never)).toBe(4096);
104
122
  });
105
123
  });
106
124
  });
package/src/index.ts CHANGED
@@ -4,8 +4,14 @@ import { resolvePort } from "./port";
4
4
 
5
5
  const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
6
6
 
7
+ /** HTTP timeout for Envoy calls — prevent hanging when NATS/Envoy is unavailable. */
8
+ const CALL_TIMEOUT_MS = 5_000;
9
+
7
10
  async function call(path: string, init?: RequestInit) {
8
- const res = await fetch(`${root}${path}`, init);
11
+ const res = await fetch(`${root}${path}`, {
12
+ ...init,
13
+ signal: init?.signal ?? AbortSignal.timeout(CALL_TIMEOUT_MS),
14
+ });
9
15
  const text = await res.text();
10
16
  if (!res.ok) throw new Error(text || `${res.status}`);
11
17
  return text;
@@ -15,6 +21,8 @@ export default async (input: { serverUrl: URL }) => {
15
21
  const registryDir = process.env.OC_REGISTRY;
16
22
  let activeSessionID: string | null = null;
17
23
  let _activeFile: string | null = null;
24
+ /** Cached port — resolved asynchronously, null until first successful resolution. */
25
+ let resolvedPort: number | null = null;
18
26
 
19
27
  const registryFile = (sessionID: string) =>
20
28
  registryDir ? `${registryDir}/${sessionID}.json` : null;
@@ -36,20 +44,26 @@ export default async (input: { serverUrl: URL }) => {
36
44
  };
37
45
 
38
46
  let portWarningLogged = false;
39
- const currentPort = () => {
40
- const port = resolvePort(input.serverUrl);
47
+ const refreshPort = async (): Promise<number | null> => {
48
+ const port = await resolvePort(input.serverUrl);
41
49
  if (!port && !portWarningLogged) {
42
50
  portWarningLogged = true;
43
51
  console.error(
44
52
  `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
45
53
  );
46
54
  }
47
- if (port) portWarningLogged = false;
55
+ if (port) {
56
+ portWarningLogged = false;
57
+ resolvedPort = port;
58
+ }
48
59
  return port;
49
60
  };
50
61
 
51
- const syncPort = (sessionID?: string) => {
52
- const value = currentPort();
62
+ /** Return cached port synchronously tools need a port value inline. */
63
+ const currentPort = () => resolvedPort;
64
+
65
+ const syncPort = async (sessionID?: string): Promise<boolean> => {
66
+ const value = await refreshPort();
53
67
  if (!value) return false;
54
68
  if (sessionID) update(sessionID, { port: value });
55
69
  return true;
@@ -57,7 +71,9 @@ export default async (input: { serverUrl: URL }) => {
57
71
 
58
72
  const fetchSession = async (sessionID: string) => {
59
73
  try {
60
- const res = await fetch(`${input.serverUrl}/session/${sessionID}`);
74
+ const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
75
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
76
+ });
61
77
  if (!res.ok) return null;
62
78
  const session = await res.json();
63
79
  return { id: session.id, title: session.title || "" };
@@ -70,10 +86,16 @@ export default async (input: { serverUrl: URL }) => {
70
86
  const registryFiles = new Set<string>();
71
87
 
72
88
  if (registryDir) {
73
- syncPort();
89
+ // Defer port resolution to background — never block plugin init.
90
+ // The port sync loop retries every second until resolved.
74
91
  const timer = setInterval(() => {
75
- if (syncPort(activeSessionID ?? undefined)) clearInterval(timer);
92
+ syncPort(activeSessionID ?? undefined).then((resolved) => {
93
+ if (resolved) clearInterval(timer);
94
+ });
76
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(() => {});
77
99
 
78
100
  // Heartbeat: re-subscribe every 2 minutes to refresh envoy_sessions TTL (5-min)
79
101
  const heartbeatInterval = setInterval(
@@ -96,6 +118,7 @@ export default async (input: { serverUrl: URL }) => {
96
118
  );
97
119
 
98
120
  process.on("exit", () => {
121
+ clearInterval(timer);
99
122
  clearInterval(heartbeatInterval);
100
123
  for (const f of registryFiles) {
101
124
  try {
@@ -108,7 +131,7 @@ export default async (input: { serverUrl: URL }) => {
108
131
  return {
109
132
  event: registryDir
110
133
  ? async ({ event }: { event: { type?: string; properties?: Record<string, unknown> } }) => {
111
- if (activeSessionID) syncPort(activeSessionID);
134
+ if (activeSessionID) syncPort(activeSessionID).catch(() => {});
112
135
 
113
136
  if (
114
137
  event.type === "session.status" &&
@@ -120,7 +143,7 @@ export default async (input: { serverUrl: URL }) => {
120
143
  _activeFile = registryFile(sessionID);
121
144
  const session = await fetchSession(sessionID);
122
145
  if (session) update(sessionID, { session });
123
- syncPort(sessionID);
146
+ await syncPort(sessionID);
124
147
  const port = currentPort();
125
148
  if (port) {
126
149
  call("/v1/interests/subscribe", {
@@ -155,12 +178,12 @@ export default async (input: { serverUrl: URL }) => {
155
178
  tool: {
156
179
  envoy_subscribe: tool({
157
180
  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.",
181
+ "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.",
159
182
  args: {
160
183
  topics: tool.schema
161
184
  .array(tool.schema.string())
162
185
  .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"
186
+ "NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
164
187
  ),
165
188
  },
166
189
  async execute(args, ctx) {
package/src/port.ts CHANGED
@@ -1,4 +1,18 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
+
3
+ type ExecFn = (
4
+ command: string,
5
+ args: string[],
6
+ options: { encoding: string }
7
+ ) => string | Promise<string>;
8
+
9
+ const defaultExec: ExecFn = (command, args, options) =>
10
+ new Promise<string>((resolve, reject) => {
11
+ execFile(command, args, { encoding: options.encoding as BufferEncoding }, (error, stdout) => {
12
+ if (error) reject(error);
13
+ else resolve(stdout as string);
14
+ });
15
+ });
2
16
 
3
17
  /**
4
18
  * Resolve the serve port from the server URL, with ss(8) fallback.
@@ -8,17 +22,17 @@ import { execFileSync } from "node:child_process";
8
22
  * 2. ss -tlnp PID match (finds listening port by process ID)
9
23
  * 3. null (caller decides how to handle)
10
24
  */
11
- export function resolvePort(
25
+ export async function resolvePort(
12
26
  serverUrl: URL,
13
- exec: typeof execFileSync = execFileSync
14
- ): number | null {
27
+ exec: ExecFn = defaultExec
28
+ ): Promise<number | null> {
15
29
  // Try URL.port first (standard path for non-default ports like 4096, 13381)
16
30
  const urlPort = Number.parseInt(serverUrl.port, 10);
17
31
  if (Number.isFinite(urlPort) && urlPort > 0) return urlPort;
18
32
 
19
33
  // Fallback: find listening port via ss(8) by PID
20
34
  try {
21
- const output = exec("ss", ["-tlnp"], { encoding: "utf-8" }) as string;
35
+ const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
22
36
  for (const line of output.split("\n")) {
23
37
  if (!line.includes(`pid=${process.pid}`)) continue;
24
38
  const parts = line.trim().split(/\s+/);
@@ -32,3 +46,4 @@ export function resolvePort(
32
46
 
33
47
  return null;
34
48
  }
49
+ // trigger publish