@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/README.md +35 -9
- package/dist/dispatch.js +643 -225
- package/dist/src/server.js +836 -284
- package/package.json +1 -1
- package/skills/dispatch/references/api.md +16 -3
- package/skills/dispatch/references/issues.md +5 -1
- package/src/server.ts +226 -190
package/package.json
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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;
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
272
|
-
return
|
|
420
|
+
if (activeSessionID === sessionID) activeSessionTitle = title;
|
|
421
|
+
return title;
|
|
273
422
|
});
|
|
274
|
-
if (
|
|
275
|
-
await
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
};
|