@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.
@@ -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