@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.2
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 +32 -1
- package/dist/bridge/api.js +22 -3
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +348 -0
- package/dist/bridge/console.js.map +1 -0
- package/dist/bridge/harness.js +883 -0
- package/dist/bridge/harness.js.map +1 -0
- package/dist/bridge/remote.js +9 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +69 -4
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +54 -6
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +13 -1
- package/dist/doctor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/screenshot.js +7 -6
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/terrain.js +116 -0
- package/dist/tools/terrain.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Commands.luau +524 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +282 -49
- package/plugin/src/Format.luau +114 -0
- package/plugin/src/Prompt.luau +436 -0
- package/plugin/src/ScriptEdit.luau +48 -0
- package/plugin/src/Visuals.luau +1409 -1332
- package/plugin/src/handlers/Capture.luau +68 -20
- package/plugin/src/handlers/Terrain.luau +371 -0
- package/plugin/src/init.server.luau +829 -725
- package/scripts/test-console.mjs +529 -0
- package/scripts/test-failover.mjs +47 -0
- package/scripts/test-plugin.mjs +85 -82
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a coding agent next to the user, on the panel's behalf.
|
|
3
|
+
*
|
|
4
|
+
* The console panel has a prompt line, and free text typed into it is not a
|
|
5
|
+
* command -- it is a request for an agent. MCP gives no way to deliver that to
|
|
6
|
+
* the agent already attached to this bridge: a server answers clients, it never
|
|
7
|
+
* calls them, so there is no inbound channel into a session someone is sitting
|
|
8
|
+
* in. What there IS, on every machine that has one of these tools installed, is
|
|
9
|
+
* a headless mode. So the bridge starts its own.
|
|
10
|
+
*
|
|
11
|
+
* The agent it starts connects back to this same bridge as an ordinary MCP
|
|
12
|
+
* client -- the port is taken, so its server becomes a peer and proxies -- and
|
|
13
|
+
* drives the same Studio the user is looking at. Its output is streamed into
|
|
14
|
+
* the console log line by line, which is what makes the panel its face rather
|
|
15
|
+
* than a black box with a spinner.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately a registry rather than a `claude` integration. Users arrive with
|
|
18
|
+
* whatever harness they already use, and every one of them ships the same three
|
|
19
|
+
* things: a binary, a one-shot flag, and a way to ask for machine-readable
|
|
20
|
+
* output. Adding one is a table entry, and a harness nobody wrote an adapter
|
|
21
|
+
* for still works through `generic`, which simply prints what it prints.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
const NOTHING = { lines: [] };
|
|
29
|
+
/** JSON if it parses, null otherwise. Harness stdout carries plain lines too. */
|
|
30
|
+
function parse(line) {
|
|
31
|
+
const text = line.trim();
|
|
32
|
+
if (text === "" || (text[0] !== "{" && text[0] !== "["))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A tool call written the way the console writes its own. */
|
|
42
|
+
/**
|
|
43
|
+
* What to do with a line that is not the JSON we expected.
|
|
44
|
+
*
|
|
45
|
+
* Never nothing. Dropping unrecognised input is precisely how the opencode
|
|
46
|
+
* adapter printed a blank run for months: it understood none of what it was
|
|
47
|
+
* sent and said so by staying quiet. A line nobody can parse is still evidence,
|
|
48
|
+
* and dim is the level for evidence.
|
|
49
|
+
*/
|
|
50
|
+
function unparsed(line) {
|
|
51
|
+
const text = line.trim();
|
|
52
|
+
return text === "" ? NOTHING : { lines: [{ level: "dim", message: text }] };
|
|
53
|
+
}
|
|
54
|
+
function toolLine(name, input) {
|
|
55
|
+
const short = name.replace(/^mcp__[^_]+__/, "").replace(/^mcp__/, "");
|
|
56
|
+
let detail;
|
|
57
|
+
if (input !== null && typeof input === "object") {
|
|
58
|
+
const parts = Object.entries(input)
|
|
59
|
+
.filter(([, value]) => typeof value === "string" || typeof value === "number")
|
|
60
|
+
.slice(0, 2)
|
|
61
|
+
// Flattened before it is cut, not after: a Luau snippet passed to
|
|
62
|
+
// execute_luau is multi-line, and 32 characters of it took a newline
|
|
63
|
+
// along into a log whose rows are lines.
|
|
64
|
+
.map(([key, value]) => key + "=" + String(value).replace(/\s+/g, " ").slice(0, 32));
|
|
65
|
+
if (parts.length > 0)
|
|
66
|
+
detail = parts.join(" ");
|
|
67
|
+
}
|
|
68
|
+
return { level: "call", message: short, detail };
|
|
69
|
+
}
|
|
70
|
+
/** Collapses a paragraph into the one-line rows a console log can hold. */
|
|
71
|
+
function say(text) {
|
|
72
|
+
return text
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((piece) => piece.trim())
|
|
75
|
+
.filter((piece) => piece !== "")
|
|
76
|
+
.map((piece) => ({ level: "reply", message: piece }));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Claude Code. `--print` with `stream-json` emits one JSON object per step,
|
|
80
|
+
* which is exactly the granularity this log wants: a row per thought and a row
|
|
81
|
+
* per tool call, rather than a wall of text at the end.
|
|
82
|
+
*/
|
|
83
|
+
const claude = {
|
|
84
|
+
id: "claude",
|
|
85
|
+
label: "Claude Code",
|
|
86
|
+
bin: "claude",
|
|
87
|
+
mcpFlag: () => ["--mcp-config", mcpConfig()],
|
|
88
|
+
argv: (prompt, session) => [
|
|
89
|
+
...(session === null ? [] : ["--resume", session]),
|
|
90
|
+
"--print",
|
|
91
|
+
prompt,
|
|
92
|
+
"--output-format",
|
|
93
|
+
"stream-json",
|
|
94
|
+
"--verbose",
|
|
95
|
+
// A headless agent cannot show an approval dialog, so what it may do is
|
|
96
|
+
// decided here rather than asked for later. Studio tools and nothing else:
|
|
97
|
+
// this prompt came from a Studio panel, and a request typed there is not
|
|
98
|
+
// consent to edit the user's disk.
|
|
99
|
+
"--allowedTools",
|
|
100
|
+
"mcp__rbx-studio",
|
|
101
|
+
"--permission-mode",
|
|
102
|
+
"dontAsk",
|
|
103
|
+
],
|
|
104
|
+
read: (line) => {
|
|
105
|
+
const event = parse(line);
|
|
106
|
+
if (event === null)
|
|
107
|
+
return unparsed(line);
|
|
108
|
+
if (event.type === "system" && event.subtype === "init") {
|
|
109
|
+
return { lines: [], session: event.session_id };
|
|
110
|
+
}
|
|
111
|
+
if (event.type === "assistant") {
|
|
112
|
+
const lines = [];
|
|
113
|
+
for (const part of event.message?.content ?? []) {
|
|
114
|
+
if (part.type === "text" && String(part.text).trim() !== "")
|
|
115
|
+
lines.push(...say(part.text));
|
|
116
|
+
if (part.type === "tool_use")
|
|
117
|
+
lines.push(toolLine(part.name, part.input));
|
|
118
|
+
}
|
|
119
|
+
return { lines, session: event.session_id };
|
|
120
|
+
}
|
|
121
|
+
if (event.type === "result") {
|
|
122
|
+
const cost = typeof event.total_cost_usd === "number" ? "$" + event.total_cost_usd.toFixed(4) : "";
|
|
123
|
+
const took = typeof event.duration_ms === "number" ? (event.duration_ms / 1000).toFixed(1) + "s" : "";
|
|
124
|
+
const detail = [took, cost].filter((piece) => piece !== "").join(" ");
|
|
125
|
+
return {
|
|
126
|
+
lines: [
|
|
127
|
+
{
|
|
128
|
+
level: event.is_error === true ? "error" : "ok",
|
|
129
|
+
message: event.is_error === true ? "agent failed" : "agent done",
|
|
130
|
+
detail: detail === "" ? undefined : detail,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
session: event.session_id,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return NOTHING;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* OpenAI Codex CLI. `exec` is its headless verb and `--json` its event stream.
|
|
141
|
+
*
|
|
142
|
+
* Documented shape, one JSON object per line:
|
|
143
|
+
*
|
|
144
|
+
* {"type":"thread.started","thread_id":"019cec77-..."}
|
|
145
|
+
* {"type":"turn.started"}
|
|
146
|
+
* {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
|
|
147
|
+
* {"type":"item.started", "item":{"type":"mcp_tool_call","server":"...","tool":"..."}}
|
|
148
|
+
* {"type":"turn.completed","usage":{...}}
|
|
149
|
+
* {"type":"turn.failed","error":{"message":"..."}}
|
|
150
|
+
*
|
|
151
|
+
* Two things here were wrong and are worth naming, because both fail quietly.
|
|
152
|
+
*
|
|
153
|
+
* The session id is `thread_id` on `thread.started`, not `session_id` on a
|
|
154
|
+
* `session.created` that codex never sends -- so every panel prompt started a
|
|
155
|
+
* new conversation and "continuing" was a lie.
|
|
156
|
+
*
|
|
157
|
+
* And global flags must precede the `resume` subcommand: codex rejects
|
|
158
|
+
* `--skip-git-repo-check` placed after it, so the old argv could only ever
|
|
159
|
+
* work on the FIRST turn and broke on every follow-up.
|
|
160
|
+
*/
|
|
161
|
+
const codex = {
|
|
162
|
+
id: "codex",
|
|
163
|
+
label: "Codex CLI",
|
|
164
|
+
bin: "codex",
|
|
165
|
+
argv: (prompt, session) => [
|
|
166
|
+
"exec",
|
|
167
|
+
"--json",
|
|
168
|
+
"--skip-git-repo-check",
|
|
169
|
+
...(session === null ? [] : ["resume", session]),
|
|
170
|
+
prompt,
|
|
171
|
+
],
|
|
172
|
+
read: (line) => {
|
|
173
|
+
const event = parse(line);
|
|
174
|
+
if (event === null)
|
|
175
|
+
return unparsed(line);
|
|
176
|
+
if (event.type === "thread.started") {
|
|
177
|
+
return { lines: [], session: event.thread_id };
|
|
178
|
+
}
|
|
179
|
+
if (event.type === "turn.completed") {
|
|
180
|
+
return { lines: [{ level: "ok", message: "agent done" }] };
|
|
181
|
+
}
|
|
182
|
+
if (event.type === "turn.failed" || event.type === "error") {
|
|
183
|
+
const message = event.error?.message ?? event.message ?? "agent failed";
|
|
184
|
+
return { lines: [{ level: "error", message: String(message) }] };
|
|
185
|
+
}
|
|
186
|
+
const item = event.item;
|
|
187
|
+
if (item === undefined)
|
|
188
|
+
return NOTHING;
|
|
189
|
+
// Tools report twice, `item.started` then `item.completed`. The started one
|
|
190
|
+
// is taken because it is the one that arrives while the work is happening,
|
|
191
|
+
// which is what a live log is for; taking both printed every call twice.
|
|
192
|
+
if (item.type === "agent_message" && event.type === "item.completed") {
|
|
193
|
+
return { lines: say(String(item.text ?? "")) };
|
|
194
|
+
}
|
|
195
|
+
if (event.type === "item.started") {
|
|
196
|
+
if (item.type === "mcp_tool_call")
|
|
197
|
+
return { lines: [toolLine(String(item.tool ?? "tool"), item.arguments)] };
|
|
198
|
+
if (item.type === "command_execution")
|
|
199
|
+
return { lines: [toolLine("shell", item.command)] };
|
|
200
|
+
}
|
|
201
|
+
if (item.type === "error" && event.type === "item.completed") {
|
|
202
|
+
return { lines: [{ level: "error", message: String(item.message ?? "agent failed") }] };
|
|
203
|
+
}
|
|
204
|
+
return NOTHING;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* opencode. `run` is one-shot; `--format json` turns it into an event stream.
|
|
209
|
+
*
|
|
210
|
+
* The envelope here was WRONG until it was recorded from a live run, and wrong
|
|
211
|
+
* in the way that produces silence rather than an error: it expected
|
|
212
|
+
* `message.part.updated`, `session.created` and `session.idle`, none of which
|
|
213
|
+
* opencode emits. Every line fell through to NOTHING, so a prompt started the
|
|
214
|
+
* agent, the agent answered, and the panel printed a blank run and went idle.
|
|
215
|
+
*
|
|
216
|
+
* What it actually emits, one JSON object per line, verified on 1.18.30:
|
|
217
|
+
*
|
|
218
|
+
* {"type":"step_start", "sessionID":"ses_...", "part":{...}}
|
|
219
|
+
* {"type":"tool_use", "sessionID":"ses_...", "part":{"type":"tool","tool":"glob","state":{...}}}
|
|
220
|
+
* {"type":"text", "sessionID":"ses_...", "part":{"type":"text","text":"Hi there"}}
|
|
221
|
+
* {"type":"step_finish","sessionID":"ses_...", "part":{"tokens":{...},"cost":0.004}}
|
|
222
|
+
*
|
|
223
|
+
* `sessionID` rides on EVERY event rather than arriving in one of its own, so
|
|
224
|
+
* it is read from whatever comes first instead of being waited for.
|
|
225
|
+
*
|
|
226
|
+
* `step_finish` is per step, not per run -- a turn that calls a tool emits two
|
|
227
|
+
* of them -- so it is not "agent done". The run's end is the process exiting,
|
|
228
|
+
* which `run` already reports.
|
|
229
|
+
*/
|
|
230
|
+
const opencode = {
|
|
231
|
+
id: "opencode",
|
|
232
|
+
label: "opencode",
|
|
233
|
+
bin: "opencode",
|
|
234
|
+
argv: (prompt, session) => [
|
|
235
|
+
"run",
|
|
236
|
+
...(session === null ? [] : ["--session", session]),
|
|
237
|
+
"--format",
|
|
238
|
+
"json",
|
|
239
|
+
prompt,
|
|
240
|
+
],
|
|
241
|
+
read: (line) => {
|
|
242
|
+
const event = parse(line);
|
|
243
|
+
if (event === null)
|
|
244
|
+
return unparsed(line);
|
|
245
|
+
const session = typeof event.sessionID === "string" ? event.sessionID : undefined;
|
|
246
|
+
const kind = event.type;
|
|
247
|
+
const part = event.part ?? {};
|
|
248
|
+
if (kind === "text" && typeof part.text === "string") {
|
|
249
|
+
return { lines: say(part.text), session };
|
|
250
|
+
}
|
|
251
|
+
if (kind === "tool_use" && typeof part.tool === "string") {
|
|
252
|
+
return { lines: [toolLine(part.tool, part.state?.input)], session };
|
|
253
|
+
}
|
|
254
|
+
if (kind === "error") {
|
|
255
|
+
const message = event.error ?? part.error ?? "agent failed";
|
|
256
|
+
return { lines: [{ level: "error", message: String(message) }], session };
|
|
257
|
+
}
|
|
258
|
+
// step_start and step_finish are turn bookkeeping. They carry the token
|
|
259
|
+
// count and cost, which the panel already gets from the run's own summary,
|
|
260
|
+
// and printing a row per step would double the log for nothing.
|
|
261
|
+
return { lines: [], session };
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Anything else on PATH, printed as it prints.
|
|
266
|
+
*
|
|
267
|
+
* Worth having even though it understands nothing: a harness with no adapter
|
|
268
|
+
* still shows its work in the panel, which is the whole point, and writing a
|
|
269
|
+
* real adapter later only changes how tidy it looks.
|
|
270
|
+
*/
|
|
271
|
+
function generic(id, bin, label) {
|
|
272
|
+
return {
|
|
273
|
+
id,
|
|
274
|
+
label,
|
|
275
|
+
bin,
|
|
276
|
+
argv: (prompt) => ["-p", prompt],
|
|
277
|
+
read: (line) => line.trim() === "" ? NOTHING : { lines: [{ level: "dim", message: line.trim() }] },
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* DeepSeek Harness. `--profile headless` is its one-shot verb: one fresh
|
|
282
|
+
* session, the final answer on stdout, exit.
|
|
283
|
+
*
|
|
284
|
+
* No event stream and no `--resume` on that profile -- both belong to its
|
|
285
|
+
* terminal and SDK profiles -- so this reads plain lines and starts a new
|
|
286
|
+
* conversation each time. That is a real limitation rather than a gap in this
|
|
287
|
+
* adapter, and it is why `say` gets the whole answer at the end instead of a
|
|
288
|
+
* row per step.
|
|
289
|
+
*
|
|
290
|
+
* The overlay is passed on every run rather than asking the user to install it,
|
|
291
|
+
* so a prompt typed in the panel reaches Studio whether or not they have
|
|
292
|
+
* merged the row into their own patch layer.
|
|
293
|
+
*/
|
|
294
|
+
const dsh = {
|
|
295
|
+
id: "dsh",
|
|
296
|
+
label: "DeepSeek Harness",
|
|
297
|
+
bin: "dsh",
|
|
298
|
+
//[[ Built here rather than through `mcpFlag`, because order is load-bearing.
|
|
299
|
+
//
|
|
300
|
+
// `dsh [options] [command] [args...]`: `--patch` is a launcher option and the
|
|
301
|
+
// prompt is a positional argument for the booted profile, so the flag has to
|
|
302
|
+
// come first. `mcpFlag` appends, which would have put it after the prompt.
|
|
303
|
+
//]]
|
|
304
|
+
argv: (prompt) => ["--profile", "headless", "--patch", dshOverlay(), prompt],
|
|
305
|
+
read: (line) => line.trim() === "" ? NOTHING : { lines: [{ level: "reply", message: line.trim() }] },
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Gemini CLI. `-p` is its headless verb; `--output-format stream-json` turns it
|
|
309
|
+
* into an event stream of `init`, `message`, `tool_use`, `tool_result`, `error`
|
|
310
|
+
* and `result`.
|
|
311
|
+
*
|
|
312
|
+
* No documented way to resume a headless session, so every prompt is a fresh
|
|
313
|
+
* conversation. That is the CLI's limitation rather than this adapter's, and it
|
|
314
|
+
* is why `session` is ignored here instead of being passed to a flag that does
|
|
315
|
+
* not exist.
|
|
316
|
+
*/
|
|
317
|
+
const gemini = {
|
|
318
|
+
id: "gemini",
|
|
319
|
+
label: "Gemini CLI",
|
|
320
|
+
bin: "gemini",
|
|
321
|
+
argv: (prompt) => ["--output-format", "stream-json", "-p", prompt],
|
|
322
|
+
read: (line) => {
|
|
323
|
+
const event = parse(line);
|
|
324
|
+
if (event === null)
|
|
325
|
+
return unparsed(line);
|
|
326
|
+
if (event.type === "init") {
|
|
327
|
+
return { lines: [], session: event.session_id ?? event.sessionId };
|
|
328
|
+
}
|
|
329
|
+
if (event.type === "message") {
|
|
330
|
+
// Both sides of the conversation come through here; the user's half is
|
|
331
|
+
// the prompt that was just typed into the panel, and echoing it back
|
|
332
|
+
// reads as the agent repeating the question.
|
|
333
|
+
if (event.role === "user")
|
|
334
|
+
return NOTHING;
|
|
335
|
+
return { lines: say(String(event.content ?? event.text ?? "")) };
|
|
336
|
+
}
|
|
337
|
+
if (event.type === "tool_use") {
|
|
338
|
+
return { lines: [toolLine(String(event.name ?? event.tool ?? "tool"), event.args ?? event.input)] };
|
|
339
|
+
}
|
|
340
|
+
if (event.type === "error") {
|
|
341
|
+
return { lines: [{ level: "error", message: String(event.message ?? "agent failed") }] };
|
|
342
|
+
}
|
|
343
|
+
if (event.type === "result") {
|
|
344
|
+
return { lines: [{ level: "ok", message: "agent done" }] };
|
|
345
|
+
}
|
|
346
|
+
return NOTHING;
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Cursor Agent. `-p` with `--output-format stream-json`, an envelope shaped
|
|
351
|
+
* closely after Claude's -- `system`/`assistant`/`result` with `session_id` on
|
|
352
|
+
* every event -- but with its own tool shape: `tool_call` carries a single key
|
|
353
|
+
* naming the kind of call, `readToolCall`, `writeToolCall` or `function`.
|
|
354
|
+
*/
|
|
355
|
+
const cursor = {
|
|
356
|
+
id: "cursor",
|
|
357
|
+
label: "Cursor Agent",
|
|
358
|
+
bin: "cursor-agent",
|
|
359
|
+
argv: (prompt, session) => [
|
|
360
|
+
...(session === null ? [] : ["--resume", session]),
|
|
361
|
+
"--output-format",
|
|
362
|
+
"stream-json",
|
|
363
|
+
"-p",
|
|
364
|
+
prompt,
|
|
365
|
+
],
|
|
366
|
+
read: (line) => {
|
|
367
|
+
const event = parse(line);
|
|
368
|
+
if (event === null)
|
|
369
|
+
return unparsed(line);
|
|
370
|
+
const session = typeof event.session_id === "string" ? event.session_id : undefined;
|
|
371
|
+
if (event.type === "assistant") {
|
|
372
|
+
const parts = event.message?.content;
|
|
373
|
+
const text = Array.isArray(parts)
|
|
374
|
+
? parts.filter((piece) => piece?.type === "text").map((piece) => piece.text).join("")
|
|
375
|
+
: "";
|
|
376
|
+
return { lines: say(String(text)), session };
|
|
377
|
+
}
|
|
378
|
+
if (event.type === "tool_call" && event.subtype === "started") {
|
|
379
|
+
const call = event.tool_call ?? {};
|
|
380
|
+
const named = Object.keys(call)[0] ?? "tool";
|
|
381
|
+
const inner = call[named] ?? {};
|
|
382
|
+
return { lines: [toolLine(inner.name ?? named, inner.args)], session };
|
|
383
|
+
}
|
|
384
|
+
if (event.type === "result") {
|
|
385
|
+
return {
|
|
386
|
+
lines: [
|
|
387
|
+
event.is_error === true
|
|
388
|
+
? { level: "error", message: "agent failed" }
|
|
389
|
+
: { level: "ok", message: "agent done" },
|
|
390
|
+
],
|
|
391
|
+
session,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (event.type === "error") {
|
|
395
|
+
return { lines: [{ level: "error", message: String(event.message ?? "agent failed") }], session };
|
|
396
|
+
}
|
|
397
|
+
return { lines: [], session };
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Crush. `run` takes the prompt and prints the answer as plain text -- there is
|
|
402
|
+
* no event stream and no resume, so this is `generic` with the right verb.
|
|
403
|
+
*
|
|
404
|
+
* The verb matters: the generic adapter guesses `-p`, which crush rejects as an
|
|
405
|
+
* unknown flag. A harness that is merely unstructured still works; one invoked
|
|
406
|
+
* wrongly does not run at all.
|
|
407
|
+
*/
|
|
408
|
+
const crush = {
|
|
409
|
+
id: "crush",
|
|
410
|
+
label: "Crush",
|
|
411
|
+
bin: "crush",
|
|
412
|
+
argv: (prompt) => ["run", prompt],
|
|
413
|
+
read: (line) => line.trim() === "" ? NOTHING : { lines: [{ level: "reply", message: line.trim() }] },
|
|
414
|
+
};
|
|
415
|
+
/**
|
|
416
|
+
* Reads Claude Code's `stream-json` envelope, which several other CLIs copy.
|
|
417
|
+
*
|
|
418
|
+
* Amp says so outright ("Claude Code-compatible protocol"), and the shape is
|
|
419
|
+
* the same three events with the same field names: `system`/`init` carrying
|
|
420
|
+
* `session_id`, `assistant` carrying `message.content[]` of `text` and
|
|
421
|
+
* `tool_use` parts, and `result` ending the run. Sharing the reader means a fix
|
|
422
|
+
* to one is a fix to all of them, and means a new CLI that adopts the envelope
|
|
423
|
+
* costs an argv and nothing else.
|
|
424
|
+
*
|
|
425
|
+
* `result` detail is left to the caller: Claude reports cost and duration in
|
|
426
|
+
* fields the others do not have.
|
|
427
|
+
*/
|
|
428
|
+
function readAnthropicStream(line) {
|
|
429
|
+
const event = parse(line);
|
|
430
|
+
if (event === null)
|
|
431
|
+
return unparsed(line);
|
|
432
|
+
if (event.type === "system" && event.subtype === "init") {
|
|
433
|
+
return { lines: [], session: event.session_id };
|
|
434
|
+
}
|
|
435
|
+
if (event.type === "assistant") {
|
|
436
|
+
const lines = [];
|
|
437
|
+
for (const part of event.message?.content ?? []) {
|
|
438
|
+
if (part.type === "text" && String(part.text).trim() !== "")
|
|
439
|
+
lines.push(...say(part.text));
|
|
440
|
+
if (part.type === "tool_use")
|
|
441
|
+
lines.push(toolLine(part.name, part.input));
|
|
442
|
+
}
|
|
443
|
+
return { lines, session: event.session_id };
|
|
444
|
+
}
|
|
445
|
+
if (event.type === "result") {
|
|
446
|
+
return {
|
|
447
|
+
lines: [
|
|
448
|
+
event.is_error === true
|
|
449
|
+
? { level: "error", message: "agent failed" }
|
|
450
|
+
: { level: "ok", message: "agent done" },
|
|
451
|
+
],
|
|
452
|
+
session: event.session_id,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return NOTHING;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Sourcegraph Amp. `-x` is its execute verb and `--stream-json` its event
|
|
459
|
+
* stream, in Claude Code's own envelope.
|
|
460
|
+
*
|
|
461
|
+
* Continuing is a different COMMAND rather than a flag -- `amp threads continue
|
|
462
|
+
* <id>` -- so the resumed argv is not the first-run argv with something added,
|
|
463
|
+
* which is why this builds both shapes rather than appending.
|
|
464
|
+
*/
|
|
465
|
+
const amp = {
|
|
466
|
+
id: "amp",
|
|
467
|
+
label: "Amp",
|
|
468
|
+
bin: "amp",
|
|
469
|
+
argv: (prompt, session) => session === null
|
|
470
|
+
? ["-x", "--stream-json", prompt]
|
|
471
|
+
: ["threads", "continue", session, "-x", "--stream-json", prompt],
|
|
472
|
+
read: readAnthropicStream,
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* Qwen Code. A Gemini CLI fork, and it kept the headless interface: `-p` with
|
|
476
|
+
* `--output-format stream-json`, and the same `init`/`message`/`tool_use`
|
|
477
|
+
* events.
|
|
478
|
+
*/
|
|
479
|
+
const qwen = {
|
|
480
|
+
id: "qwen",
|
|
481
|
+
label: "Qwen Code",
|
|
482
|
+
bin: "qwen",
|
|
483
|
+
argv: (prompt) => ["--output-format", "stream-json", "-p", prompt],
|
|
484
|
+
read: (line) => gemini.read(line),
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Factory Droid. `exec` is headless; `--output-format json` answers with ONE
|
|
488
|
+
* object at the end rather than a stream.
|
|
489
|
+
*
|
|
490
|
+
* `stream-json` exists and is deprecated -- it prints a warning -- and its
|
|
491
|
+
* replacement, `stream-jsonrpc`, is a request/response protocol that expects a
|
|
492
|
+
* client writing to stdin, not a log to read. A single object at the end is the
|
|
493
|
+
* honest fit for a one-shot prompt, at the cost of the panel showing the work
|
|
494
|
+
* only when it is done.
|
|
495
|
+
*
|
|
496
|
+
* `--auto medium` because a headless agent cannot answer an approval prompt,
|
|
497
|
+
* and `low` refuses the file edits an agent asked to build something needs.
|
|
498
|
+
*/
|
|
499
|
+
const droid = {
|
|
500
|
+
id: "droid",
|
|
501
|
+
label: "Factory Droid",
|
|
502
|
+
bin: "droid",
|
|
503
|
+
argv: (prompt, session) => [
|
|
504
|
+
"exec",
|
|
505
|
+
"--output-format",
|
|
506
|
+
"json",
|
|
507
|
+
"--auto",
|
|
508
|
+
"medium",
|
|
509
|
+
...(session === null ? [] : ["--session-id", session]),
|
|
510
|
+
prompt,
|
|
511
|
+
],
|
|
512
|
+
read: (line) => {
|
|
513
|
+
const event = parse(line);
|
|
514
|
+
if (event === null)
|
|
515
|
+
return unparsed(line);
|
|
516
|
+
const text = event.result ?? event.output ?? event.message ?? event.text;
|
|
517
|
+
const session = event.session_id ?? event.sessionId;
|
|
518
|
+
if (typeof text === "string" && text.trim() !== "")
|
|
519
|
+
return { lines: say(text), session };
|
|
520
|
+
return { lines: [], session };
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* Block's goose. `run -t` is its headless verb, and it speaks `stream-json`.
|
|
525
|
+
*
|
|
526
|
+
* Sessions are named rather than identified: `--resume -n <name>` reopens one,
|
|
527
|
+
* so the name goose reports is stored where the other harnesses store an id.
|
|
528
|
+
*/
|
|
529
|
+
const goose = {
|
|
530
|
+
id: "goose",
|
|
531
|
+
label: "goose",
|
|
532
|
+
bin: "goose",
|
|
533
|
+
argv: (prompt, session) => [
|
|
534
|
+
"run",
|
|
535
|
+
...(session === null ? [] : ["--resume", "-n", session]),
|
|
536
|
+
"--output-format",
|
|
537
|
+
"stream-json",
|
|
538
|
+
"-t",
|
|
539
|
+
prompt,
|
|
540
|
+
],
|
|
541
|
+
read: (line) => {
|
|
542
|
+
const event = parse(line);
|
|
543
|
+
if (event === null)
|
|
544
|
+
return unparsed(line);
|
|
545
|
+
const session = event.session_id ?? event.session ?? event.name;
|
|
546
|
+
const kind = event.type;
|
|
547
|
+
if (kind === "text" || kind === "message" || kind === "assistant") {
|
|
548
|
+
const text = event.text ?? event.content ?? event.message?.content;
|
|
549
|
+
if (typeof text === "string")
|
|
550
|
+
return { lines: say(text), session };
|
|
551
|
+
}
|
|
552
|
+
if (kind === "tool_use" || kind === "tool_request") {
|
|
553
|
+
return { lines: [toolLine(String(event.name ?? event.tool ?? "tool"), event.input)], session };
|
|
554
|
+
}
|
|
555
|
+
if (kind === "error") {
|
|
556
|
+
return { lines: [{ level: "error", message: String(event.message ?? "agent failed") }], session };
|
|
557
|
+
}
|
|
558
|
+
return { lines: [], session };
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
/**
|
|
562
|
+
* GitHub Copilot CLI. `-p` runs one prompt; there is no structured output --
|
|
563
|
+
* the request for it is open and unimplemented -- so this reads plain text.
|
|
564
|
+
*
|
|
565
|
+
* `--no-ask-user` matters more here than the missing JSON: without it Copilot
|
|
566
|
+
* pauses for clarification, and a headless run with nothing to answer it sits
|
|
567
|
+
* there until it is killed. `--allow-tool` is scoped to this server's tools for
|
|
568
|
+
* the same reason Claude's is: a prompt typed in a Studio panel is not consent
|
|
569
|
+
* to touch the disk.
|
|
570
|
+
*/
|
|
571
|
+
const copilot = {
|
|
572
|
+
id: "copilot",
|
|
573
|
+
label: "GitHub Copilot CLI",
|
|
574
|
+
bin: "copilot",
|
|
575
|
+
argv: (prompt) => ["-p", prompt, "--no-ask-user", "--allow-tool", "mcp__rbx-studio"],
|
|
576
|
+
read: (line) => line.trim() === "" ? NOTHING : { lines: [{ level: "reply", message: line.trim() }] },
|
|
577
|
+
};
|
|
578
|
+
/**
|
|
579
|
+
* Aider. `--message` is one shot; `--yes` answers the confirmations it would
|
|
580
|
+
* otherwise block on. No event stream, so its prose arrives as prose.
|
|
581
|
+
*/
|
|
582
|
+
const aider = {
|
|
583
|
+
id: "aider",
|
|
584
|
+
label: "Aider",
|
|
585
|
+
bin: "aider",
|
|
586
|
+
argv: (prompt) => ["--message", prompt, "--yes", "--no-pretty"],
|
|
587
|
+
read: (line) => line.trim() === "" ? NOTHING : { lines: [{ level: "reply", message: line.trim() }] },
|
|
588
|
+
};
|
|
589
|
+
const REGISTRY = [
|
|
590
|
+
claude,
|
|
591
|
+
codex,
|
|
592
|
+
opencode,
|
|
593
|
+
dsh,
|
|
594
|
+
gemini,
|
|
595
|
+
cursor,
|
|
596
|
+
amp,
|
|
597
|
+
qwen,
|
|
598
|
+
droid,
|
|
599
|
+
goose,
|
|
600
|
+
copilot,
|
|
601
|
+
aider,
|
|
602
|
+
crush,
|
|
603
|
+
];
|
|
604
|
+
/**
|
|
605
|
+
* Whether an MCP client's self-reported name is this harness.
|
|
606
|
+
*
|
|
607
|
+
* The names nearly agree and not quite: the harness ids here are `claude`,
|
|
608
|
+
* `codex`, `opencode`, and the clients introduce themselves as `claude-code`,
|
|
609
|
+
* `codex`, `opencode`. Compared with the punctuation stripped and either one
|
|
610
|
+
* allowed to be the prefix, which covers `claude` against `claude-code`
|
|
611
|
+
* without needing a second table to keep in sync with the first.
|
|
612
|
+
*
|
|
613
|
+
* Deliberately not clever. A wrong match here picks the wrong agent, and the
|
|
614
|
+
* cost of no match is only that the first installed one is used instead --
|
|
615
|
+
* which is the behaviour this replaces, so a miss is never worse than before.
|
|
616
|
+
*/
|
|
617
|
+
export function matchesClient(harness, clientName) {
|
|
618
|
+
const flat = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
619
|
+
const id = flat(harness.id);
|
|
620
|
+
const name = flat(clientName);
|
|
621
|
+
if (id.length === 0 || name.length === 0)
|
|
622
|
+
return false;
|
|
623
|
+
return name.startsWith(id) || id.startsWith(name);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Where a binary is on PATH, or null. Found by looking rather than by running.
|
|
627
|
+
*
|
|
628
|
+
* Running `--version` to find out costs a process per candidate on every
|
|
629
|
+
* `agent` listing, and several of these tools take a second to start.
|
|
630
|
+
*
|
|
631
|
+
* The full path matters beyond the yes/no answer. On Windows every one of these
|
|
632
|
+
* tools is installed as a `.cmd` shim, and Node only applies its cmd-specific
|
|
633
|
+
* argument escaping when it can SEE that extension. Handed a bare "claude" with
|
|
634
|
+
* `shell: true` it concatenates instead, which strips every quote in the
|
|
635
|
+
* command line -- that is not just a formatting bug, it is the difference
|
|
636
|
+
* between passing a prompt and pasting whatever is in it into a shell.
|
|
637
|
+
*/
|
|
638
|
+
function whereIs(bin) {
|
|
639
|
+
const paths = (process.env["PATH"] ?? "").split(delimiter).filter((entry) => entry !== "");
|
|
640
|
+
const extensions = process.platform === "win32"
|
|
641
|
+
? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT").split(";")
|
|
642
|
+
: [""];
|
|
643
|
+
for (const dir of paths) {
|
|
644
|
+
for (const extension of extensions) {
|
|
645
|
+
for (const candidate of extension === ""
|
|
646
|
+
? [join(dir, bin)]
|
|
647
|
+
: [join(dir, bin + extension), join(dir, bin + extension.toLowerCase())]) {
|
|
648
|
+
if (existsSync(candidate))
|
|
649
|
+
return candidate;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
export function installed() {
|
|
656
|
+
return REGISTRY.filter((entry) => whereIs(entry.bin) !== null);
|
|
657
|
+
}
|
|
658
|
+
export function find(id) {
|
|
659
|
+
return REGISTRY.find((entry) => entry.id === id || entry.bin === id);
|
|
660
|
+
}
|
|
661
|
+
/** Written once per process, reused by every run. */
|
|
662
|
+
let configPath = null;
|
|
663
|
+
/**
|
|
664
|
+
* An MCP config naming THIS package, written to a file.
|
|
665
|
+
*
|
|
666
|
+
* A file rather than the inline JSON string these flags also accept, because
|
|
667
|
+
* the string is a brace-and-quote-heavy argument crossing a Windows command
|
|
668
|
+
* line, and the first attempt at it arrived at the other end with every quote
|
|
669
|
+
* gone. A path has nothing in it to mangle.
|
|
670
|
+
*
|
|
671
|
+
* Points at our own entry rather than at `npx` so the agent cannot end up
|
|
672
|
+
* driving a different version of the bridge than the one it is talking to.
|
|
673
|
+
*/
|
|
674
|
+
function mcpConfig() {
|
|
675
|
+
if (configPath !== null)
|
|
676
|
+
return configPath;
|
|
677
|
+
const entry = resolve(dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
678
|
+
const dir = mkdtempSync(join(tmpdir(), "rbx-studio-mcp-"));
|
|
679
|
+
configPath = join(dir, "mcp.json");
|
|
680
|
+
writeFileSync(configPath, JSON.stringify({
|
|
681
|
+
mcpServers: {
|
|
682
|
+
"rbx-studio": {
|
|
683
|
+
command: process.execPath,
|
|
684
|
+
args: [entry],
|
|
685
|
+
//[[ Stated here as well as in the spawn's own environment.
|
|
686
|
+
//
|
|
687
|
+
// The agent inherits RBX_STUDIO_MCP_SPAWNED, but the agent is not
|
|
688
|
+
// what connects: it launches its own copy of this server as a child,
|
|
689
|
+
// and whether that child inherits the agent's environment is the
|
|
690
|
+
// agent's business, not ours. Claude's did not, so the spawned server
|
|
691
|
+
// announced itself as a stranger -- the panel said "2 MCP clients
|
|
692
|
+
// connected" on every prompt, and a stopped agent stayed in `clients`
|
|
693
|
+
// until the 90-second stale timeout swept it.
|
|
694
|
+
//
|
|
695
|
+
// Written into the config the agent reads, it survives whatever the
|
|
696
|
+
// agent does to the environment on the way.
|
|
697
|
+
//]]
|
|
698
|
+
env: { RBX_STUDIO_MCP_SPAWNED: "1" },
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
}), "utf8");
|
|
702
|
+
return configPath;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Ends a run, and everything it started.
|
|
706
|
+
*
|
|
707
|
+
* `child.kill()` is not enough, and the way it fails is the worst kind: it
|
|
708
|
+
* returns true, the streams close, the promise settles, the panel says
|
|
709
|
+
* "stopping the agent" and goes back to idle -- and the agent carries on
|
|
710
|
+
* editing the user's place. Measured on Windows, `claude.exe` was still running
|
|
711
|
+
* five seconds after a cancel that reported success, because what actually died
|
|
712
|
+
* was the launcher holding the pipes while the work ran in a descendant.
|
|
713
|
+
*
|
|
714
|
+
* So the tree goes, not the process. `taskkill /T` walks the children on
|
|
715
|
+
* Windows; elsewhere the child was given its own process group at spawn, and
|
|
716
|
+
* the negative pid signals all of it. Both fall back to the plain kill, because
|
|
717
|
+
* a cancel that half works still beats one that throws.
|
|
718
|
+
*/
|
|
719
|
+
function kill(child) {
|
|
720
|
+
const pid = child.pid;
|
|
721
|
+
if (pid === undefined)
|
|
722
|
+
return;
|
|
723
|
+
if (process.platform === "win32") {
|
|
724
|
+
try {
|
|
725
|
+
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
/* falls through to the plain kill */
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
try {
|
|
734
|
+
process.kill(-pid, "SIGTERM");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
/* the group is gone, or was never made */
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
child.kill();
|
|
742
|
+
}
|
|
743
|
+
let overlayPath = null;
|
|
744
|
+
/**
|
|
745
|
+
* The same server, written as the Cordis overlay row dsh reads.
|
|
746
|
+
*
|
|
747
|
+
* A second format rather than a translation of the first, because the two are
|
|
748
|
+
* not the same statement: dsh's row also fixes the tool namespace, the per-call
|
|
749
|
+
* timeout, and what happens when Studio is not open yet. Kept byte-identical in
|
|
750
|
+
* meaning to `config/dsh.cordis.yml`, which is the copy a user merges into
|
|
751
|
+
* their own profile -- this one exists so a panel prompt works before they have.
|
|
752
|
+
*/
|
|
753
|
+
function dshOverlay() {
|
|
754
|
+
if (overlayPath !== null)
|
|
755
|
+
return overlayPath;
|
|
756
|
+
const entry = resolve(dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
757
|
+
const dir = mkdtempSync(join(tmpdir(), "rbx-studio-mcp-"));
|
|
758
|
+
overlayPath = join(dir, "rbx-studio.cordis.yml");
|
|
759
|
+
// Written by hand rather than through a YAML library: it is six fixed keys
|
|
760
|
+
// and one interpolated path, and a dependency for that is a dependency to
|
|
761
|
+
// keep patched forever.
|
|
762
|
+
writeFileSync(overlayPath, [
|
|
763
|
+
"- insert:",
|
|
764
|
+
" - id: mcp-rbx-studio",
|
|
765
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
766
|
+
" config:",
|
|
767
|
+
" serverName: rbx-studio",
|
|
768
|
+
" transport: stdio",
|
|
769
|
+
` command: ${JSON.stringify(process.execPath)}`,
|
|
770
|
+
` args: [${JSON.stringify(entry)}]`,
|
|
771
|
+
" toolCallTimeoutMs: 60000",
|
|
772
|
+
" failOnStartupError: false",
|
|
773
|
+
// The same declaration as the Claude config's `env`, for the same
|
|
774
|
+
// reason: what dsh passes to a server it starts is dsh's business.
|
|
775
|
+
" env:",
|
|
776
|
+
" RBX_STUDIO_MCP_SPAWNED: '1'",
|
|
777
|
+
"",
|
|
778
|
+
].join("\n"), "utf8");
|
|
779
|
+
return overlayPath;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Starts `harness` on `prompt`, reporting every step through `emit`.
|
|
783
|
+
*
|
|
784
|
+
* stdout is read line by line because every adapter here is line-oriented, and
|
|
785
|
+
* a chunk boundary lands mid-object often enough that not buffering shows up as
|
|
786
|
+
* randomly missing rows rather than as an obvious break.
|
|
787
|
+
*/
|
|
788
|
+
export function run(harness, prompt, options) {
|
|
789
|
+
const argv = [...harness.argv(prompt, options.session)];
|
|
790
|
+
if (harness.mcpFlag !== undefined)
|
|
791
|
+
argv.push(...harness.mcpFlag());
|
|
792
|
+
const executable = whereIs(harness.bin);
|
|
793
|
+
if (executable === null) {
|
|
794
|
+
options.emit({ level: "error", message: harness.bin + " is not on PATH" });
|
|
795
|
+
return { cancel: () => { }, done: Promise.resolve(null) };
|
|
796
|
+
}
|
|
797
|
+
let child;
|
|
798
|
+
try {
|
|
799
|
+
child = spawn(executable, argv, {
|
|
800
|
+
cwd: options.cwd,
|
|
801
|
+
// Only for the .cmd shims Windows installs these as, which CreateProcess
|
|
802
|
+
// will not run. Node escapes arguments correctly for those precisely
|
|
803
|
+
// because the extension is visible in the path; anything else is spawned
|
|
804
|
+
// directly, with no shell to quote for.
|
|
805
|
+
shell: /\.(cmd|bat)$/i.test(executable),
|
|
806
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
807
|
+
// Its own process group, so cancelling can take the whole tree down with
|
|
808
|
+
// one signal. See `kill`.
|
|
809
|
+
detached: process.platform !== "win32",
|
|
810
|
+
// Travels down to the MCP server this agent will start, which is the only
|
|
811
|
+
// thing in a position to tell the bridge that its client was not a person
|
|
812
|
+
// opening a second editor. See ClientView.spawned.
|
|
813
|
+
env: { ...process.env, RBX_STUDIO_MCP_SPAWNED: "1" },
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
catch (cause) {
|
|
817
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
818
|
+
options.emit({ level: "error", message: "could not start " + harness.bin, detail: message });
|
|
819
|
+
return { cancel: () => { }, done: Promise.resolve(null) };
|
|
820
|
+
}
|
|
821
|
+
let session = options.session;
|
|
822
|
+
let pending = "";
|
|
823
|
+
// Set by `cancel`, so a non-zero exit can be reported as the stop the user
|
|
824
|
+
// asked for rather than as a failure they did not.
|
|
825
|
+
let stopped = false;
|
|
826
|
+
const feed = (chunk) => {
|
|
827
|
+
pending += chunk;
|
|
828
|
+
let cut = pending.indexOf("\n");
|
|
829
|
+
while (cut !== -1) {
|
|
830
|
+
const line = pending.slice(0, cut);
|
|
831
|
+
pending = pending.slice(cut + 1);
|
|
832
|
+
const reading = harness.read(line);
|
|
833
|
+
if (reading.session !== undefined && reading.session !== "")
|
|
834
|
+
session = reading.session;
|
|
835
|
+
for (const row of reading.lines)
|
|
836
|
+
options.emit(row);
|
|
837
|
+
cut = pending.indexOf("\n");
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
child.stdout?.setEncoding("utf8");
|
|
841
|
+
child.stdout?.on("data", feed);
|
|
842
|
+
// stderr is where these tools put the reason they refused to start, and that
|
|
843
|
+
// is the single most useful line the panel can show when nothing happens.
|
|
844
|
+
let complaint = "";
|
|
845
|
+
child.stderr?.setEncoding("utf8");
|
|
846
|
+
child.stderr?.on("data", (chunk) => {
|
|
847
|
+
complaint = (complaint + chunk).slice(-400);
|
|
848
|
+
});
|
|
849
|
+
const done = new Promise((settle) => {
|
|
850
|
+
child.on("error", (cause) => {
|
|
851
|
+
options.emit({
|
|
852
|
+
level: "error",
|
|
853
|
+
message: "could not start " + harness.bin,
|
|
854
|
+
detail: cause.message,
|
|
855
|
+
});
|
|
856
|
+
settle(session);
|
|
857
|
+
});
|
|
858
|
+
child.on("close", (code) => {
|
|
859
|
+
if (pending !== "")
|
|
860
|
+
feed("\n");
|
|
861
|
+
if (stopped) {
|
|
862
|
+
options.emit({ level: "warn", message: "agent stopped" });
|
|
863
|
+
}
|
|
864
|
+
else if (code !== 0 && code !== null) {
|
|
865
|
+
const tail = complaint.trim().split("\n").slice(-2).join(" ");
|
|
866
|
+
options.emit({
|
|
867
|
+
level: "error",
|
|
868
|
+
message: harness.label + " exited " + code,
|
|
869
|
+
detail: tail === "" ? undefined : tail,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
settle(session);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
return {
|
|
876
|
+
cancel: () => {
|
|
877
|
+
stopped = true;
|
|
878
|
+
kill(child);
|
|
879
|
+
},
|
|
880
|
+
done,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
//# sourceMappingURL=harness.js.map
|