@sjawhar/opencode-legion-envoy 6.4.1 → 6.5.0

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/src/server.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { agentSubject, dispatchToolSpecs, zodSchemaApi } from "@legion/contracts";
4
+ import { agentSubject, zodSchemaApi } from "@legion/contracts";
5
5
  import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
6
6
  import { resolveDispatchConfig } from "@legion/envoy-client/dispatch-config";
7
- import { executeDispatchTool } from "@legion/envoy-client/dispatch-execute";
8
7
  import { dispatchFirstSkillFile } from "@legion/envoy-client/dispatch-first";
9
8
  import { machineID } from "@legion/envoy-client/machine";
10
9
  import {
@@ -13,7 +12,7 @@ import {
13
12
  toMessageMetadata,
14
13
  } from "@legion/envoy-client/tool-contract";
15
14
  import { createEnvoyClient } from "@legion/envoy-client/transport";
16
- import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool";
15
+ import { tool } from "@opencode-ai/plugin/tool";
17
16
  import { logger } from "./log";
18
17
  import { resolvePort } from "./port";
19
18
 
@@ -29,7 +28,37 @@ const skillsDirectory = [
29
28
  path.resolve(moduleDirectory, "../../../skills"),
30
29
  ].find((dir) => existsSync(dir));
31
30
 
32
- function toolSpec(name: string): ToolSpec {
31
+ const PACKAGE_NAME = "@sjawhar/opencode-legion-envoy";
32
+
33
+ /** The package root: the nearest directory above `from` whose package.json names this package.
34
+ * The packed server.js sits in dist/src/, the repo's server.ts in src/. A manifest on the way up
35
+ * that is missing, unreadable or not JSON is passed over. */
36
+ function packageRoot(from: string): string {
37
+ for (let directory = from; ; directory = path.dirname(directory)) {
38
+ if (manifestName(path.join(directory, "package.json")) === PACKAGE_NAME) return directory;
39
+ if (path.dirname(directory) === directory) {
40
+ throw new Error(`envoy: no ${PACKAGE_NAME} package.json above ${from}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ /** A manifest's `name`, or undefined when the file is missing, unreadable or not JSON. */
46
+ function manifestName(manifest: string): unknown {
47
+ try {
48
+ const parsed: unknown = JSON.parse(readFileSync(manifest, "utf8"));
49
+ return typeof parsed === "object" && parsed !== null && "name" in parsed
50
+ ? parsed.name
51
+ : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /** Holds the `dispatch` shim the `shell.env` hook puts first on every shell command's PATH. */
58
+ const PLUGIN_BIN = path.join(packageRoot(moduleDirectory), "bin");
59
+
60
+ /** A spec by its name, typed so a misspelled name fails `tsc` rather than the plugin's load. */
61
+ function toolSpec(name: (typeof envoyToolSpecs)[number]["name"]): ToolSpec {
33
62
  const spec = envoyToolSpecs.find((candidate) => candidate.name === name);
34
63
  if (spec === undefined) throw new Error(`envoy tool contract has no ${name}`);
35
64
  return spec;
@@ -46,12 +75,12 @@ const sessionsSpec = toolSpec("envoy_sessions");
46
75
 
47
76
  export default async (input: { serverUrl: URL }) => {
48
77
  const cwd = process.cwd();
49
- // Dispatch configuration never prevents Envoy from loading. Disabled native
50
- // tools are visible in the host log, including normal unconfigured installs.
78
+ // Dispatch configuration never prevents Envoy from loading. A Dispatch the configuration
79
+ // cannot reach is visible in the host log, including normal unconfigured installs.
51
80
  const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
52
81
  if (!dispatchConfig.enabled) {
53
82
  logger.warn(
54
- `envoy: dispatch tools disabled — ${dispatchConfig.error ?? "no Dispatch URL configured"}`
83
+ `envoy: Dispatch disabled — ${dispatchConfig.error ?? "no Dispatch URL configured"}`
55
84
  );
56
85
  }
57
86
  // With Dispatch configured, every session carries the dispatch-first skill as an instruction
@@ -182,33 +211,6 @@ export default async (input: { serverUrl: URL }) => {
182
211
  clearInterval(heartbeatInterval);
183
212
  });
184
213
 
185
- // Native Dispatch tools are present only when the shared configuration has
186
- // both a server URL and bearer token. Each tool returns its details as
187
- // OpenCode metadata so the post-execution hook can subscribe to its topic.
188
- const dispatchTools: Record<string, ToolDefinition> = {};
189
- if (dispatchConfig.enabled) {
190
- for (const spec of dispatchToolSpecs) {
191
- dispatchTools[spec.name] = tool({
192
- description: spec.description,
193
- args: spec.arguments(zodSchemaApi(tool.schema)) as never,
194
- async execute(args, ctx) {
195
- ctx.metadata({ title: "Dispatch" });
196
- const result = await executeDispatchTool({
197
- tool: spec.name,
198
- args: args as Record<string, unknown>,
199
- cwd: ctx.directory,
200
- host: "opencode",
201
- sessionId: ctx.sessionID,
202
- sessionTitle: (await fetchTitle(ctx.sessionID)) ?? undefined,
203
- config: dispatchConfig,
204
- env: process.env,
205
- });
206
- return { title: "Dispatch", output: result.text, metadata: result.details };
207
- },
208
- });
209
- }
210
- }
211
-
212
214
  return {
213
215
  config: (
214
216
  cfg: { skills?: { paths?: string[] }; instructions?: string[] } & Record<string, unknown>
@@ -226,6 +228,23 @@ export default async (input: { serverUrl: URL }) => {
226
228
  if (!cfg.instructions.includes(dispatchFirst)) cfg.instructions.push(dispatchFirst);
227
229
  }
228
230
  },
231
+ // The `dispatch` command in every shell command: first on PATH, and told which OpenCode
232
+ // session runs it and that session's title.
233
+ "shell.env": async (
234
+ input: { cwd: string; sessionID?: string; callID?: string },
235
+ output: { env: Record<string, string> }
236
+ ) => {
237
+ output.env.PATH = `${PLUGIN_BIN}${path.delimiter}${output.env.PATH ?? process.env.PATH ?? ""}`;
238
+ output.env.DISPATCH_HOST = "opencode";
239
+ // OpenCode spreads output.env over its own process.env, so an absent key keeps the inherited value.
240
+ output.env.DISPATCH_SESSION_ID = "";
241
+ output.env.DISPATCH_SESSION_TITLE = "";
242
+ if (input.sessionID) {
243
+ output.env.DISPATCH_SESSION_ID = input.sessionID;
244
+ const title = trackedSessions.get(input.sessionID)?.title;
245
+ if (title) output.env.DISPATCH_SESSION_TITLE = title;
246
+ }
247
+ },
229
248
  event: async ({
230
249
  event,
231
250
  }: {
@@ -295,7 +314,6 @@ export default async (input: { serverUrl: URL }) => {
295
314
  clearInterval(heartbeatInterval);
296
315
  },
297
316
  tool: {
298
- ...dispatchTools,
299
317
  envoy_subscribe: tool({
300
318
  description: subscribeSpec.description,
301
319
  args: subscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,