@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 +1 -1
- package/dist/index.js +97 -94
- package/package.json +1 -1
- package/src/__tests__/index.test.ts +164 -6
- package/src/index.ts +130 -106
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.
|
|
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
|
|
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 (
|
|
12402
|
+
const syncPort = async () => {
|
|
12425
12403
|
const value = await refreshPort();
|
|
12426
|
-
|
|
12427
|
-
return false;
|
|
12428
|
-
if (sessionID)
|
|
12429
|
-
update(sessionID, { port: value });
|
|
12430
|
-
return true;
|
|
12404
|
+
return value !== null;
|
|
12431
12405
|
};
|
|
12432
|
-
const
|
|
12406
|
+
const fetchTitle = async (sessionID) => {
|
|
12433
12407
|
try {
|
|
12434
|
-
const res = await fetch(`${input.serverUrl}
|
|
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
|
|
12440
|
-
return
|
|
12413
|
+
const data = await res.json();
|
|
12414
|
+
return data.title ?? null;
|
|
12441
12415
|
} catch {
|
|
12442
12416
|
return null;
|
|
12443
12417
|
}
|
|
12444
12418
|
};
|
|
12445
|
-
const
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
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:
|
|
12450
|
+
event: async ({
|
|
12451
|
+
event
|
|
12452
|
+
}) => {
|
|
12484
12453
|
if (activeSessionID)
|
|
12485
|
-
syncPort(
|
|
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
|
-
|
|
12491
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
44
|
+
const syncPort = async (): Promise<boolean> => {
|
|
66
45
|
const value = await refreshPort();
|
|
67
|
-
|
|
68
|
-
if (sessionID) update(sessionID, { port: value });
|
|
69
|
-
return true;
|
|
46
|
+
return value !== null;
|
|
70
47
|
};
|
|
71
48
|
|
|
72
|
-
|
|
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}
|
|
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
|
|
79
|
-
return
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
133
|
-
|
|
134
|
-
|
|
101
|
+
event: async ({
|
|
102
|
+
event,
|
|
103
|
+
}: {
|
|
104
|
+
event: { type?: string; properties?: Record<string, unknown> };
|
|
105
|
+
}) => {
|
|
106
|
+
if (activeSessionID) syncPort().catch(() => {});
|
|
135
107
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
};
|