@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/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/asks.md +3 -3
- package/skills/dispatch/references/issues.md +5 -1
- package/skills/legion-architect/SKILL.md +8 -4
- package/skills/legion-retro/SKILL.md +1 -1
- package/skills/legion-worker/references/merge-gate.md +9 -5
- 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
|
|
@@ -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
|
|
23
|
-
--option '
|
|
24
|
-
--option '
|
|
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;
|
|
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
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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,
|
|
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)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
//
|
|
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
|
};
|