@sjawhar/opencode-legion-envoy 7.8.1 → 8.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "7.8.1",
3
+ "version": "8.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -6,9 +6,22 @@ commands do not cover.
6
6
  ## Agent authentication
7
7
 
8
8
  Use a personal Dispatch token: a human mints it in Dispatch **Settings → Agent tokens** and supplies
9
- it to the agent through `dispatch.token` in `~/.config/opencode/envoy.json` or `DISPATCH_TOKEN`.
10
- The server records the minting human as the owner of that session's writes. `DISPATCH_AGENT_TOKEN`
11
- is the shared devbox fallback; do not configure it for an individual agent.
9
+ it to the agent through `DISPATCH_TOKEN` or `dispatch.token` in the `envoy.json` the agent's
10
+ session reads. Under an Oh My Pi profile other than `default`, a Claude Code `CLAUDE_CONFIG_DIR` or
11
+ an OpenCode `OPENCODE_CONFIG_DIR`, that is the `envoy.json` in the session's own directory; any
12
+ other session reads the file `ENVOY_CONFIG_FILE` names when it is set, else
13
+ `~/.config/legion/envoy.json` (under `$XDG_CONFIG_HOME` when set). The plugin hands the path it
14
+ chose to the session's commands in `ENVOY_CONFIG_FILE` beside `DISPATCH_HOST`, and the `dispatch`
15
+ command reads the file `ENVOY_CONFIG_FILE` names whenever `DISPATCH_HOST` is set, else its host
16
+ plugin's rule; in a Claude Code Bash that lacks both, as a subagent's can,
17
+ `CLAUDE_CODE_CHILD_SESSION=1` makes the host `claude`, so the command reads `CLAUDE_CONFIG_DIR`'s
18
+ file. A `dispatch.token` written `!<command>` is a command whose output is the token. A checkout's
19
+ `.envoy/envoy.json` may set `dispatch.serverUrl` only with the `dispatch.token` beside it, and
20
+ `dispatch.token` only with its own `dispatch.serverUrl` beside it; an `envoy` section there is not
21
+ read. The file's token goes only to its own `dispatch.serverUrl`: a
22
+ `DISPATCH_URL` naming another server needs `DISPATCH_TOKEN` or `DISPATCH_TOKEN_FILE`. The server
23
+ records the minting human as the owner of that session's writes.
24
+ `DISPATCH_AGENT_TOKEN` is the shared devbox fallback; do not configure it for an individual agent.
12
25
 
13
26
  The deployed Dispatch server's browser origin is configured separately with
14
27
  `DISPATCH_SERVER_URL` in the deployment `compose/.env`. Do not change an
@@ -107,7 +107,11 @@ Link the pull request that delivers the issue with `--external-link` when you op
107
107
  renders its state and checks from that link, and `dispatch read` lists it under `External links:`.
108
108
  The command is authenticated with the same bearer as every
109
109
  other `dispatch` command: a Legion pane reads it from the `DISPATCH_TOKEN_FILE` path the daemon sets on
110
- the pane; an OMP session outside Legion reads `dispatch.token` from `~/.config/opencode/envoy.json`.
110
+ the pane; any other session's `dispatch` command reads `dispatch.token` from the `envoy.json` its
111
+ host's plugin read, which the plugin hands it in `ENVOY_CONFIG_FILE`. By that plugin's rule, that
112
+ is an OMP session's profile directory under an omp profile other than `default`, a Claude Code
113
+ session's `CLAUDE_CONFIG_DIR` (its subagents' too) or an OpenCode session's `OPENCODE_CONFIG_DIR`,
114
+ else the file `ENVOY_CONFIG_FILE` names, else `~/.config/legion/envoy.json`.
111
115
 
112
116
  A write to an issue still in `triage` answers once with `… is still in triage …`; move the status
113
117
  when work has started.
package/src/server.ts CHANGED
@@ -2,17 +2,16 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { agentSubject, zodSchemaApi } from "@legion/contracts";
5
- import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
6
- import { resolveDispatchConfig } from "@legion/envoy-client/dispatch-config";
7
5
  import { dispatchFirstSkillFile } from "@legion/envoy-client/dispatch-first";
8
6
  import { machineID } from "@legion/envoy-client/machine";
7
+ import { sessionConfigFile, sessionSettings } from "@legion/envoy-client/session-settings";
9
8
  import {
10
9
  envoyToolSpecs,
11
10
  type ToolSpec,
12
11
  toMessageMetadata,
13
12
  } from "@legion/envoy-client/tool-contract";
14
- import { createEnvoyClient } from "@legion/envoy-client/transport";
15
- import { tool } from "@opencode-ai/plugin/tool";
13
+ import { createEnvoyClient, type EnvoyClient } from "@legion/envoy-client/transport";
14
+ import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool";
16
15
  import { logger } from "./log";
17
16
  import { resolvePort } from "./port";
18
17
 
@@ -73,22 +72,151 @@ const roleSetSpec = toolSpec("envoy_role_set");
73
72
  const whoamiSpec = toolSpec("envoy_whoami");
74
73
  const sessionsSpec = toolSpec("envoy_sessions");
75
74
 
75
+ /** The Envoy tools, on the listener `client` reaches: what OpenCode registers while Envoy is on.
76
+ * `port` and `title` are read at each call, since both arrive after the plugin loads. */
77
+ function envoyTools(
78
+ client: EnvoyClient,
79
+ port: () => number | null,
80
+ title: () => string | null
81
+ ): Record<string, ToolDefinition> {
82
+ return {
83
+ envoy_subscribe: tool({
84
+ description: subscribeSpec.description,
85
+ args: subscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
86
+ async execute(args, ctx) {
87
+ ctx.metadata({ title: "Envoy subscribe" });
88
+ return JSON.stringify(
89
+ await client.subscribe({
90
+ sessionID: ctx.sessionID,
91
+ directory: ctx.directory,
92
+ topics: args.topics as string[],
93
+ port: port() ?? 0,
94
+ title: title() ?? "",
95
+ driving: true,
96
+ })
97
+ );
98
+ },
99
+ }),
100
+ envoy_unsubscribe: tool({
101
+ description: unsubscribeSpec.description,
102
+ args: unsubscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
103
+ async execute(args, ctx) {
104
+ ctx.metadata({ title: "Envoy unsubscribe" });
105
+ await client.unsubscribe({
106
+ sessionID: ctx.sessionID,
107
+ topics: (args.topics as string[] | undefined) ?? [],
108
+ });
109
+ return "ok";
110
+ },
111
+ }),
112
+ envoy_list: tool({
113
+ description: listSpec.description,
114
+ args: listSpec.arguments(zodSchemaApi(tool.schema)) as never,
115
+ async execute(_args, ctx) {
116
+ ctx.metadata({ title: "Envoy list" });
117
+ return JSON.stringify(await client.getInterest(ctx.sessionID));
118
+ },
119
+ }),
120
+ envoy_send: tool({
121
+ description: sendSpec.description,
122
+ args: sendSpec.arguments(zodSchemaApi(tool.schema)) as never,
123
+ async execute(args, ctx) {
124
+ ctx.metadata({ title: "Envoy send" });
125
+ return JSON.stringify(
126
+ await client.send({
127
+ sourceSessionID: ctx.sessionID,
128
+ targetSessionID: args.session_id as string,
129
+ message: args.message as string,
130
+ ...toMessageMetadata(args as never),
131
+ })
132
+ );
133
+ },
134
+ }),
135
+ envoy_publish: tool({
136
+ description: publishSpec.description,
137
+ args: publishSpec.arguments(zodSchemaApi(tool.schema)) as never,
138
+ async execute(args, ctx) {
139
+ ctx.metadata({ title: "Envoy publish" });
140
+ return JSON.stringify(
141
+ await client.publish({
142
+ sourceSessionID: ctx.sessionID,
143
+ topic: args.topic as string,
144
+ message: args.message as string,
145
+ ...toMessageMetadata(args as never),
146
+ })
147
+ );
148
+ },
149
+ }),
150
+ envoy_role_set: tool({
151
+ description: roleSetSpec.description,
152
+ args: roleSetSpec.arguments(zodSchemaApi(tool.schema)) as never,
153
+ async execute(args, ctx) {
154
+ ctx.metadata({ title: "Set Envoy role" });
155
+ const result = await client.setRole({
156
+ sessionID: ctx.sessionID,
157
+ role: args.role as string,
158
+ });
159
+ // A hard claim always lands; the discriminant exists for soft claims.
160
+ if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`);
161
+ return JSON.stringify(result.interest);
162
+ },
163
+ }),
164
+ envoy_whoami: tool({
165
+ description: whoamiSpec.description,
166
+ args: whoamiSpec.arguments(zodSchemaApi(tool.schema)) as never,
167
+ async execute(_args, ctx) {
168
+ ctx.metadata({ title: "Envoy whoami" });
169
+ return JSON.stringify(
170
+ {
171
+ session_id: ctx.sessionID,
172
+ machine_id: machineID(),
173
+ port: port(),
174
+ dir: ctx.directory,
175
+ },
176
+ null,
177
+ 2
178
+ );
179
+ },
180
+ }),
181
+ envoy_sessions: tool({
182
+ description: sessionsSpec.description,
183
+ args: sessionsSpec.arguments(zodSchemaApi(tool.schema)) as never,
184
+ async execute(args, ctx) {
185
+ ctx.metadata({ title: "Envoy sessions" });
186
+ const sessions = await client.listSessions({
187
+ ...(args.dir === undefined ? {} : { directory: args.dir as string }),
188
+ ...(args.title === undefined ? {} : { title: args.title as string }),
189
+ });
190
+ const machine = args.machine as string | undefined;
191
+ return JSON.stringify(
192
+ machine ? sessions.filter((session) => session.machine_id === machine) : sessions,
193
+ null,
194
+ 2
195
+ );
196
+ },
197
+ }),
198
+ };
199
+ }
200
+
76
201
  export default async (input: { serverUrl: URL }) => {
77
202
  const cwd = process.cwd();
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.
80
- const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
81
- if (!dispatchConfig.enabled) {
82
- logger.warn(
83
- `envoy: Dispatch disabled — ${dispatchConfig.error ?? "no Dispatch URL configured"}`
84
- );
85
- }
203
+ // The serve's envoy.json, by OpenCode's rule: `$OPENCODE_CONFIG_DIR/envoy.json`, else the file
204
+ // ENVOY_CONFIG_FILE names, else the neutral file; every shell command is handed the same file
205
+ // (`shell.env`). Dispatch and Envoy are each resolved on their own from it, and why a section is
206
+ // off is logged once. Neither section, nor a file location that cannot be worked out, stops the
207
+ // plugin from loading. OpenCode opens no NATS connection, so no NATS server or seed is resolved.
208
+ const configFile = sessionConfigFile("opencode", process.env);
209
+ const { dispatch, envoy, notice } = sessionSettings(configFile, process.env, {
210
+ cwd,
211
+ nats: false,
212
+ });
213
+ if (notice !== null) logger.warn(`envoy: ${notice}`);
86
214
  // With Dispatch configured, every session carries the dispatch-first skill as an instruction
87
215
  // file: OpenCode reads instruction files into the main loop's system prompt on every request
88
216
  // and leaves them out of title and compaction requests. A package without the skill fails
89
217
  // here, naming the file, rather than serving sessions that silently lack it.
90
218
  let dispatchFirst: string | undefined;
91
- if (dispatchConfig.enabled) {
219
+ if (dispatch.enabled) {
92
220
  if (skillsDirectory === undefined) {
93
221
  throw new Error(`envoy: no skills directory beside ${moduleDirectory}`);
94
222
  }
@@ -97,8 +225,18 @@ export default async (input: { serverUrl: URL }) => {
97
225
  throw new Error(`envoy: the dispatch-first skill ${dispatchFirst} is missing`);
98
226
  }
99
227
  }
100
- const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
101
- const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
228
+ // The listener client exists only while Envoy is on: without it no tool is registered, nothing
229
+ // registers a session, and no listener is reached.
230
+ const envoyOn = envoy.enabled
231
+ ? {
232
+ heartbeatMs: envoy.heartbeatMs,
233
+ client: createEnvoyClient({
234
+ baseUrl: envoy.url,
235
+ fetch: globalThis.fetch,
236
+ token: envoy.token,
237
+ }),
238
+ }
239
+ : undefined;
102
240
  let activeSessionID: string | null = null;
103
241
  let activeSessionTitle: string | null = null;
104
242
  // All sessions that have become busy in this serve instance. The heartbeat
@@ -150,28 +288,18 @@ export default async (input: { serverUrl: URL }) => {
150
288
  }
151
289
  };
152
290
 
153
- // Defer port resolution to background — never block plugin init.
154
- // The port sync loop retries every second until resolved.
155
- const timer = setInterval(() => {
156
- syncPort().then((resolved) => {
157
- if (resolved) clearInterval(timer);
158
- });
159
- }, 1000);
160
- timer.unref(); // Don't prevent graceful shutdown if port never resolves
161
- // Fire an immediate async attempt (non-blocking)
162
- syncPort().catch(() => {});
163
-
164
291
  // driving = this process is the one running the session (it has gone busy
165
292
  // here), as opposed to a sibling re-adopted from shared on-disk state after a
166
293
  // serve restart. Envoy uses it to keep a live driver's route from being stolen
167
294
  // by another process that merely holds the same session.
168
295
  const subscribeSession = (
296
+ client: EnvoyClient,
169
297
  sessionID: string,
170
298
  title: string | null,
171
299
  port: number,
172
300
  driving: boolean
173
301
  ) =>
174
- envoy
302
+ client
175
303
  .subscribe({
176
304
  sessionID,
177
305
  directory: cwd,
@@ -193,23 +321,37 @@ export default async (input: { serverUrl: URL }) => {
193
321
  // knows the serve port and the session IDs it dispatched — not something a
194
322
  // stranger process may arrange by adopting routes.
195
323
 
196
- // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
197
- // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
198
- // just the most recently active one. Interval is env-tunable for tests/tuning.
199
- const heartbeatMs = envoyDefaults.heartbeatMs;
200
- const heartbeatInterval = setInterval(() => {
201
- const port = currentPort();
202
- if (!port) return;
203
- for (const [sessionID, info] of trackedSessions) {
204
- subscribeSession(sessionID, info.title, port, info.driving);
205
- }
206
- }, heartbeatMs);
207
- heartbeatInterval.unref?.();
324
+ // Envoy's background work, started only while Envoy is on, and what stops it.
325
+ let stopEnvoy = (): void => {};
326
+ if (envoyOn !== undefined) {
327
+ // Defer port resolution to background — never block plugin init.
328
+ // The port sync loop retries every second until resolved.
329
+ const portTimer = setInterval(() => {
330
+ syncPort().then((resolved) => {
331
+ if (resolved) clearInterval(portTimer);
332
+ });
333
+ }, 1000);
334
+ portTimer.unref(); // Don't prevent graceful shutdown if port never resolves
335
+ // Fire an immediate async attempt (non-blocking)
336
+ syncPort().catch(() => {});
208
337
 
209
- process.on("exit", () => {
210
- clearInterval(timer);
211
- clearInterval(heartbeatInterval);
212
- });
338
+ // Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
339
+ // TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
340
+ // just the most recently active one. Interval is env-tunable for tests/tuning.
341
+ const heartbeatTimer = setInterval(() => {
342
+ const port = currentPort();
343
+ if (!port) return;
344
+ for (const [sessionID, info] of trackedSessions) {
345
+ subscribeSession(envoyOn.client, sessionID, info.title, port, info.driving);
346
+ }
347
+ }, envoyOn.heartbeatMs);
348
+ heartbeatTimer.unref?.();
349
+ stopEnvoy = () => {
350
+ clearInterval(portTimer);
351
+ clearInterval(heartbeatTimer);
352
+ };
353
+ }
354
+ process.on("exit", stopEnvoy);
213
355
 
214
356
  return {
215
357
  config: (
@@ -228,14 +370,15 @@ export default async (input: { serverUrl: URL }) => {
228
370
  if (!cfg.instructions.includes(dispatchFirst)) cfg.instructions.push(dispatchFirst);
229
371
  }
230
372
  },
231
- // The `dispatch` command in every shell command: first on PATH, and told which OpenCode
232
- // session runs it and that session's title.
373
+ // The `dispatch` command in every shell command: first on PATH, handed the serve's envoy.json,
374
+ // and told which OpenCode session runs it and that session's title.
233
375
  "shell.env": async (
234
376
  input: { cwd: string; sessionID?: string; callID?: string },
235
377
  output: { env: Record<string, string> }
236
378
  ) => {
237
379
  output.env.PATH = `${PLUGIN_BIN}${path.delimiter}${output.env.PATH ?? process.env.PATH ?? ""}`;
238
380
  output.env.DISPATCH_HOST = "opencode";
381
+ if (configFile.path !== null) output.env.ENVOY_CONFIG_FILE = configFile.path;
239
382
  // OpenCode spreads output.env over its own process.env, so an absent key keeps the inherited value.
240
383
  output.env.DISPATCH_SESSION_ID = "";
241
384
  output.env.DISPATCH_SESSION_TITLE = "";
@@ -250,7 +393,7 @@ export default async (input: { serverUrl: URL }) => {
250
393
  }: {
251
394
  event: { type?: string; properties?: Record<string, unknown> };
252
395
  }) => {
253
- if (activeSessionID) syncPort().catch(() => {});
396
+ if (envoy.enabled && activeSessionID) syncPort().catch(() => {});
254
397
 
255
398
  if (
256
399
  event.type === "session.status" &&
@@ -263,28 +406,39 @@ export default async (input: { serverUrl: URL }) => {
263
406
  if (!trackedSessions.has(sessionID)) {
264
407
  trackedSessions.set(sessionID, { title: null, driving: true });
265
408
  }
266
- await syncPort();
267
- const port = currentPort();
268
- // Fetch title — best-effort, non-blocking for initial subscribe
269
- const titlePromise = fetchTitle(sessionID).then((t) => {
409
+ // The title names the session to the `dispatch` command (`shell.env`) and, while Envoy
410
+ // is on, in its registration. Best-effort, and the first registration does not wait on it.
411
+ const titlePromise = fetchTitle(sessionID).then((title) => {
412
+ if (!title) return null;
413
+ // Update tracked metadata even if this session is no longer the
414
+ // active one (another session may have become busy meanwhile).
415
+ const tracked = trackedSessions.get(sessionID);
416
+ if (tracked !== undefined) {
417
+ trackedSessions.set(sessionID, { title, driving: tracked.driving });
418
+ }
270
419
  // Guard: only update if this session is still active (prevents race on fast switches)
271
- if (t && activeSessionID === sessionID) activeSessionTitle = t;
272
- return t;
420
+ if (activeSessionID === sessionID) activeSessionTitle = title;
421
+ return title;
273
422
  });
274
- if (port) {
275
- await subscribeSession(sessionID, activeSessionTitle, port, true);
276
- // After the title arrives, send one follow-up subscribe with it.
277
- titlePromise.then((title) => {
278
- if (!title) return;
279
- // Update tracked metadata even if this session is no longer the
280
- // active one (another session may have become busy meanwhile).
281
- if (trackedSessions.has(sessionID)) {
282
- const driving = trackedSessions.get(sessionID)?.driving ?? true;
283
- trackedSessions.set(sessionID, { title, driving });
284
- subscribeSession(sessionID, title, currentPort() ?? 0, driving);
285
- }
286
- if (activeSessionID === sessionID) activeSessionTitle = title;
287
- });
423
+ if (envoyOn !== undefined) {
424
+ await syncPort();
425
+ const port = currentPort();
426
+ if (port) {
427
+ await subscribeSession(envoyOn.client, sessionID, activeSessionTitle, port, true);
428
+ // After the title arrives, send one follow-up subscribe with it.
429
+ titlePromise.then((title) => {
430
+ const tracked = trackedSessions.get(sessionID);
431
+ if (title && tracked !== undefined) {
432
+ subscribeSession(
433
+ envoyOn.client,
434
+ sessionID,
435
+ title,
436
+ currentPort() ?? 0,
437
+ tracked.driving
438
+ );
439
+ }
440
+ });
441
+ }
288
442
  }
289
443
  }
290
444
  }
@@ -304,133 +458,15 @@ export default async (input: { serverUrl: URL }) => {
304
458
  activeSessionTitle = null;
305
459
  }
306
460
  // Best-effort: drop the deleted session's interests so routing stops.
307
- envoy.unsubscribe({ sessionID: deletedID, topics: [] }).catch(() => {});
461
+ envoyOn?.client.unsubscribe({ sessionID: deletedID, topics: [] }).catch(() => {});
308
462
  }
309
463
  }
310
464
  },
311
465
  // Cleanup hook (used by tests; production relies on process 'exit').
312
- dispose: () => {
313
- clearInterval(timer);
314
- clearInterval(heartbeatInterval);
315
- },
316
- tool: {
317
- envoy_subscribe: tool({
318
- description: subscribeSpec.description,
319
- args: subscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
320
- async execute(args, ctx) {
321
- ctx.metadata({ title: "Envoy subscribe" });
322
- return JSON.stringify(
323
- await envoy.subscribe({
324
- sessionID: ctx.sessionID,
325
- directory: ctx.directory,
326
- topics: args.topics as string[],
327
- port: currentPort() ?? 0,
328
- title: activeSessionTitle ?? "",
329
- driving: true,
330
- })
331
- );
332
- },
333
- }),
334
- envoy_unsubscribe: tool({
335
- description: unsubscribeSpec.description,
336
- args: unsubscribeSpec.arguments(zodSchemaApi(tool.schema)) as never,
337
- async execute(args, ctx) {
338
- ctx.metadata({ title: "Envoy unsubscribe" });
339
- await envoy.unsubscribe({
340
- sessionID: ctx.sessionID,
341
- topics: (args.topics as string[] | undefined) ?? [],
342
- });
343
- return "ok";
344
- },
345
- }),
346
- envoy_list: tool({
347
- description: listSpec.description,
348
- args: listSpec.arguments(zodSchemaApi(tool.schema)) as never,
349
- async execute(_args, ctx) {
350
- ctx.metadata({ title: "Envoy list" });
351
- return JSON.stringify(await envoy.getInterest(ctx.sessionID));
352
- },
353
- }),
354
- envoy_send: tool({
355
- description: sendSpec.description,
356
- args: sendSpec.arguments(zodSchemaApi(tool.schema)) as never,
357
- async execute(args, ctx) {
358
- ctx.metadata({ title: "Envoy send" });
359
- return JSON.stringify(
360
- await envoy.send({
361
- sourceSessionID: ctx.sessionID,
362
- targetSessionID: args.session_id as string,
363
- message: args.message as string,
364
- ...toMessageMetadata(args as never),
365
- })
366
- );
367
- },
368
- }),
369
- envoy_publish: tool({
370
- description: publishSpec.description,
371
- args: publishSpec.arguments(zodSchemaApi(tool.schema)) as never,
372
- async execute(args, ctx) {
373
- ctx.metadata({ title: "Envoy publish" });
374
- return JSON.stringify(
375
- await envoy.publish({
376
- sourceSessionID: ctx.sessionID,
377
- topic: args.topic as string,
378
- message: args.message as string,
379
- ...toMessageMetadata(args as never),
380
- })
381
- );
382
- },
383
- }),
384
- envoy_role_set: tool({
385
- description: roleSetSpec.description,
386
- args: roleSetSpec.arguments(zodSchemaApi(tool.schema)) as never,
387
- async execute(args, ctx) {
388
- ctx.metadata({ title: "Set Envoy role" });
389
- const result = await envoy.setRole({
390
- sessionID: ctx.sessionID,
391
- role: args.role as string,
392
- });
393
- // A hard claim always lands; the discriminant exists for soft claims.
394
- if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`);
395
- return JSON.stringify(result.interest);
396
- },
397
- }),
398
- envoy_whoami: tool({
399
- description: whoamiSpec.description,
400
- args: whoamiSpec.arguments(zodSchemaApi(tool.schema)) as never,
401
- async execute(_args, ctx) {
402
- ctx.metadata({ title: "Envoy whoami" });
403
- const sessionID = ctx.sessionID;
404
- const port = currentPort();
405
- return JSON.stringify(
406
- {
407
- session_id: sessionID,
408
- machine_id: machineID(),
409
- port,
410
- dir: ctx.directory,
411
- },
412
- null,
413
- 2
414
- );
415
- },
416
- }),
417
- envoy_sessions: tool({
418
- description: sessionsSpec.description,
419
- args: sessionsSpec.arguments(zodSchemaApi(tool.schema)) as never,
420
- async execute(args, ctx) {
421
- ctx.metadata({ title: "Envoy sessions" });
422
- const sessions = await envoy.listSessions({
423
- ...(args.dir === undefined ? {} : { directory: args.dir as string }),
424
- ...(args.title === undefined ? {} : { title: args.title as string }),
425
- });
426
- const machine = args.machine as string | undefined;
427
- return JSON.stringify(
428
- machine ? sessions.filter((session) => session.machine_id === machine) : sessions,
429
- null,
430
- 2
431
- );
432
- },
433
- }),
434
- },
466
+ dispose: stopEnvoy,
467
+ tool:
468
+ envoyOn === undefined
469
+ ? {}
470
+ : envoyTools(envoyOn.client, currentPort, () => activeSessionTitle),
435
471
  };
436
472
  };