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