@sjawhar/opencode-legion-envoy 7.8.0 → 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.0",
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
@@ -19,9 +19,9 @@ If an outcome needs a reason, say so in that option's description, and the human
19
19
  carries it:
20
20
  ```bash
21
21
  dispatch ask --issue DSP-42 \
22
- --question 'The verified fix remains unavailable in production because deployment is pending. Deploying changes production and could expose a deployment problem. How should we proceed? Recommendation: deploy the verified fix now because it restores the intended behavior.' \
23
- --option 'Deploy the verified fix: Restores the fix but can expose a deployment problem.' \
24
- --option 'Hold the deploy: Avoids a production change now but leaves production without the fix.'
22
+ --question 'The fix is merged but cannot run in production: the signing certificate it needs expired, and only its owner can renew it in the registrar console. Renewing replaces the certificate every client checks. How should we proceed? Recommendation: renew it now because the fix cannot run without it.' \
23
+ --option 'Renew the certificate: Lets the fix run but replaces the certificate every client checks.' \
24
+ --option 'Leave it expired: Changes nothing now but leaves the fix unable to run.'
25
25
  ```
26
26
 
27
27
  Correct or refine an open ask in place instead of opening a second question, with
@@ -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.
@@ -197,7 +197,8 @@ credential can exist without any skill having told you so). When the evidence ge
197
197
  reachable, do not have a different running session perform the read on this tree's behalf and
198
198
  fold the result into the spec as if it were routine: write the missing capability as its own
199
199
  decision block — which external system, which read or write, why the spec needs it — addressed
200
- to the human, in the same spirit as the implementer reports a production-check gap (section 6) and leave it
200
+ to the human, in the same spirit as the implementer's ask to a person when the deployment
201
+ instructions name nobody for a deploy (section 6), and leave it
201
202
  open until the human resolves it. A workaround substituted for that record only hides the gap
202
203
  from the next tree that hits it.
203
204
 
@@ -314,9 +315,12 @@ The daemon keeps this order from its fixed table; you start none of its steps:
314
315
  It drives the changed path in production through the user's own access path and records
315
316
  what it saw on the pull request and on this issue. Sign off only after the implementer's production
316
317
  report exists. A defect it finds is a corrective child issue of this tree, not a note on a
317
- closed one; if the implementer cannot perform the deploy, it opens a `dispatch ask` that starts
318
- with the production gap and why it matters, then names the required step, its risk, and
319
- outcome-named options. The issue waits for that answer.
318
+ closed one. If the implementer cannot perform the deploy itself, it asks the agent or role the
319
+ deployment instructions name for that deploy (an Envoy role), gives it the merge commit, and
320
+ waits on the production signal the instructions name; the issue waits with it. It opens a
321
+ `dispatch ask` to a person only when the instructions name nobody for that deploy, starting
322
+ with the production gap and why it matters, then naming the required step, its risk, and
323
+ outcome-named options. No one is ever asked for approval to deploy.
320
324
 
321
325
  What returns the tree to review: a changed diff — a commit above the approved head that
322
326
  touches anything outside `docs/solutions/` and the issue's `.legion/<issue>/`, or a
@@ -180,7 +180,7 @@ dispatch message --issue <KEY> --body-file - <<'EOF'
180
180
 
181
181
  **Proofs read:** implementer <surface/command>, tester <surface/command>.
182
182
 
183
- **Production check:** <what the implementer will drive after the merge, or the deploy/restart step a human will have to perform first>
183
+ **Production check:** <what the implementer will drive after the merge, and any deploy or restart it waits on first, with the agent or role the deployment instructions name to perform it, or the person it asks when they name none>
184
184
 
185
185
  <!-- legion: {"session":"<session-id>","phase":"retro"} -->
186
186
  EOF
@@ -151,8 +151,12 @@ review posted without a completion leaves the issue in reviewing until you finis
151
151
  carrying the Legion footer, and a `dispatch message` on the issue — the reviewer and merger
152
152
  read GitHub, the architect reads the issue. When the deploy that carries the merge has not
153
153
  happened (a shared profile still holding the previous plugin release, a daemon still running
154
- the previous commit, a slot nobody has run), open a `dispatch ask` that starts with the
155
- production gap and why it matters, then names the required install or restart step, its risk,
156
- and outcome-named options. Keep the `Production:` line at `pending <what is missing>`, and
157
- complete the check once the human answers. Never record a staging pass as the production check,
158
- and never let the architect sign off on a `pending` line.
154
+ the previous commit, a slot nobody has run) and you cannot perform it yourself, ask the agent or
155
+ role the deployment instructions name for that deploy: `envoy_publish` to its role topic with the
156
+ merge commit and `expects_reply: required`, then wait on the production signal the instructions
157
+ name. Open a `dispatch ask` to a person only when the instructions name nobody for that deploy;
158
+ it starts with the production gap and why it matters, then names the required install or restart
159
+ step, its risk, and outcome-named options. Never ask anyone for approval to deploy. Keep the
160
+ `Production:` line at `pending <what is missing>`, and complete the check once that signal shows
161
+ the deploy carries the merge (or the person answers, when an ask was the route). Never record a
162
+ staging pass as the production check, and never let the architect sign off on a `pending` line.
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
  };