negotium 0.2.13 → 0.2.15

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.
Files changed (44) hide show
  1. package/dist/agent-helpers.js +13 -12
  2. package/dist/agent-helpers.js.map +6 -6
  3. package/dist/background-bash.js +11 -11
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +2 -3
  6. package/dist/browser-runtime.js.map +3 -3
  7. package/dist/{chunk-rnsnhcye.js → chunk-5g5a63vv.js} +11 -11
  8. package/dist/{chunk-rnsnhcye.js.map → chunk-5g5a63vv.js.map} +4 -4
  9. package/dist/hosted-agent.js +12 -12
  10. package/dist/hosted-agent.js.map +5 -5
  11. package/dist/main.js +582 -357
  12. package/dist/main.js.map +12 -11
  13. package/dist/mcp-factories.js +13 -12
  14. package/dist/mcp-factories.js.map +6 -6
  15. package/dist/mcp-servers.js +3 -2
  16. package/dist/mcp-servers.js.map +3 -3
  17. package/dist/prompts.js +2 -3
  18. package/dist/prompts.js.map +3 -3
  19. package/dist/query-runtime.js +2 -3
  20. package/dist/query-runtime.js.map +3 -3
  21. package/dist/registry.js +3 -3
  22. package/dist/registry.js.map +2 -2
  23. package/dist/rollout.js +1 -1
  24. package/dist/runtime/src/index.ts +9 -0
  25. package/dist/runtime/src/node-host.ts +9 -0
  26. package/dist/runtime/src/platform/background-bash/manager.ts +30 -22
  27. package/dist/runtime/src/platform/config.ts +8 -7
  28. package/dist/runtime/src/runtime/bashrs-completions.ts +74 -14
  29. package/dist/runtime/src/runtime/public-helpers.ts +8 -0
  30. package/dist/runtime/src/runtime/turn-runner.ts +6 -0
  31. package/dist/runtime/src/version.ts +1 -1
  32. package/dist/runtime-helpers.js +444 -32
  33. package/dist/runtime-helpers.js.map +10 -4
  34. package/dist/types/apps/negotium/src/mcp-servers.d.ts +16 -3
  35. package/dist/types/packages/core/src/platform/background-bash/manager.d.ts +6 -1
  36. package/dist/types/packages/core/src/platform/config.d.ts +8 -6
  37. package/dist/types/packages/core/src/runtime/bashrs-completions.d.ts +50 -0
  38. package/dist/types/packages/core/src/runtime/public-helpers.d.ts +2 -0
  39. package/dist/types/packages/core/src/version.d.ts +1 -1
  40. package/dist/vault.js +2 -3
  41. package/dist/vault.js.map +3 -3
  42. package/install-bash-rs.mjs +5 -5
  43. package/package.json +1 -1
  44. package/dist/runtime/src/mcp/background-bash-server.ts +0 -756
@@ -1,756 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Background Bash MCP server — HTTP mode (SSE + Streamable HTTP).
4
- *
5
- * Runs as a long-lived HTTP process managed by background-bash/manager.ts,
6
- * independent of any agent turn. Bash processes spawned here survive turn
7
- * boundaries; when a process exits its output is injected into the topic
8
- * via session-inbox so the model gets a new turn automatically.
9
- *
10
- * Endpoints:
11
- * GET /sse → SSE MCP transport (claude / maestro)
12
- * POST /message → SSE message handler (claude / maestro)
13
- * POST /mcp → Streamable HTTP MCP transport (codex)
14
- * GET /health → health probe (used by manager.ts)
15
- *
16
- * Why HTTP instead of stdio:
17
- * stdio servers are children of the agent SDK subprocess and are killed
18
- * when the turn ends. An HTTP server managed separately persists across
19
- * turns so background bash processes have a stable owner and can inject
20
- * completions asynchronously.
21
- */
22
- import { type ChildProcess, spawn } from "node:child_process";
23
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
24
- import { readdirSync, rmSync, statSync } from "node:fs";
25
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
26
- import { join } from "node:path";
27
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
29
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
30
- import { z } from "zod";
31
- import { deriveBgBashContextCapability } from "#platform/background-bash/context";
32
- import {
33
- BoundedOutputStream,
34
- formatBytes,
35
- type OutputSnapshot,
36
- } from "#platform/background-bash/output-buffer";
37
- import { RUN_DIR } from "#platform/config";
38
- import { appendJsonlEntry } from "#platform/jsonl";
39
- import { sessionInboxPath } from "#query/session-inbox-path";
40
- import { mcpError, mcpOk } from "./mcp-helpers";
41
-
42
- // --- CLI args ---
43
-
44
- const args = process.argv.slice(2);
45
- const port = parseInt(args.find((a) => a.startsWith("--port="))?.split("=")[1] ?? "0", 10);
46
- const runtimeCapability = process.env.NEGOTIUM_BG_BASH_CAPABILITY ?? "";
47
- const runtimeServerId = process.env.NEGOTIUM_BG_BASH_SERVER_ID ?? "";
48
-
49
- if (!port || !runtimeCapability || !runtimeServerId) {
50
- process.stderr.write(
51
- "FATAL: --port, NEGOTIUM_BG_BASH_CAPABILITY, and NEGOTIUM_BG_BASH_SERVER_ID are required\n",
52
- );
53
- process.exit(1);
54
- }
55
-
56
- // --- Process registry ---
57
-
58
- const SIGTERM_GRACE_MS = 5_000;
59
- /**
60
- * Preview budget per stream, in real UTF-8 bytes. Output beyond this is kept
61
- * only in the spill file, never dropped outright.
62
- */
63
- const MAX_OUTPUT_BYTES = 200_000;
64
- const COMPLETED_RETENTION_MS = 60 * 60_000;
65
- const MAX_COMPLETED_PROCS = 100;
66
- /** Count-based pruning ignores jobs this fresh; their turn may still be queued. */
67
- const PRUNE_GRACE_MS = 5 * 60_000;
68
- const DEFAULT_WATCH_TIMEOUT_SECONDS = 3_600;
69
- const MAX_WATCH_TIMEOUT_SECONDS = 86_400;
70
-
71
- /** Root for full-output spill files, one directory per bash id. */
72
- const SPILL_ROOT = join(RUN_DIR, "bg-bash-output");
73
-
74
- /**
75
- * One-shot match-and-notify state for `background_bash_watch`. `matched` and
76
- * `finished` are distinct: `matched` records the specific outcome (a line hit
77
- * `regex`) for the injected message; `finished` guards against `finishProc`
78
- * double-delivering once a match or timeout has already sent the one turn a
79
- * watch job promises.
80
- */
81
- interface WatchState {
82
- regex: RegExp;
83
- target: "stdout" | "stderr" | "both";
84
- matched: boolean;
85
- finished: boolean;
86
- timeoutTimer?: ReturnType<typeof setTimeout>;
87
- stdoutCarry: string;
88
- stderrCarry: string;
89
- }
90
-
91
- interface BgProc {
92
- bashId: string;
93
- userId: string;
94
- topic: string;
95
- command: string;
96
- child: ChildProcess;
97
- stdout: BoundedOutputStream;
98
- stderr: BoundedOutputStream;
99
- stdoutCursor: number;
100
- stderrCursor: number;
101
- exited: boolean;
102
- exitCode: number | null;
103
- /** False once the completion turn failed to reach the topic's inbox. */
104
- delivered: boolean;
105
- startedAt: number;
106
- killTimer?: ReturnType<typeof setTimeout>;
107
- cleanupTimer?: ReturnType<typeof setTimeout>;
108
- /** Present only for jobs started via `background_bash_watch`. */
109
- watch?: WatchState;
110
- }
111
-
112
- const procs = new Map<string, BgProc>();
113
-
114
- interface BgContext {
115
- userId: string;
116
- topic: string;
117
- }
118
-
119
- function contextCapability(context: BgContext): string {
120
- return deriveBgBashContextCapability(runtimeCapability, context.userId, context.topic);
121
- }
122
-
123
- function sameContext(proc: BgProc, context: BgContext): boolean {
124
- return proc.userId === context.userId && proc.topic === context.topic;
125
- }
126
-
127
- function newBashId(): string {
128
- return `bash_${randomBytes(6).toString("hex")}`;
129
- }
130
-
131
- function spillDir(bashId: string): string {
132
- return join(SPILL_ROOT, bashId);
133
- }
134
-
135
- function removeSpill(bashId: string): void {
136
- try {
137
- rmSync(spillDir(bashId), { recursive: true, force: true });
138
- } catch {
139
- // Best-effort: a leftover spill is swept on the next server start.
140
- }
141
- }
142
-
143
- /**
144
- * Drop spill directories left behind by a previous server process.
145
- *
146
- * Completed processes are forgotten after `COMPLETED_RETENTION_MS`, but a
147
- * crash or restart skips that cleanup, so anything older than the retention
148
- * window is unreachable and safe to remove.
149
- */
150
- function sweepStaleSpills(): void {
151
- let entries: string[];
152
- try {
153
- entries = readdirSync(SPILL_ROOT);
154
- } catch {
155
- return; // Nothing spilled yet.
156
- }
157
- const cutoff = Date.now() - COMPLETED_RETENTION_MS;
158
- for (const entry of entries) {
159
- if (procs.has(entry)) continue;
160
- const path = join(SPILL_ROOT, entry);
161
- try {
162
- if (statSync(path).mtimeMs < cutoff) rmSync(path, { recursive: true, force: true });
163
- } catch {
164
- // Ignore races with a concurrent sweep.
165
- }
166
- }
167
- }
168
-
169
- /**
170
- * Render one stream for the completion turn.
171
- *
172
- * Reports the real total, what was dropped from the preview, and where the
173
- * complete bytes live. When the spill failed we say so instead of pointing at
174
- * a file that does not hold the full output.
175
- */
176
- function describeStream(label: string, snapshot: OutputSnapshot): string | undefined {
177
- const body = snapshot.text.trim();
178
- if (!body && !snapshot.truncated) return undefined;
179
- if (!snapshot.truncated) return `${label}:\n${body}`;
180
-
181
- const notes = [
182
- `${formatBytes(snapshot.totalBytes)} total, ${formatBytes(snapshot.omittedBytes)} omitted`,
183
- ];
184
- if (snapshot.spillPath) {
185
- // The path is only good while the completed process is retained. Saying so
186
- // lets a reader that comes back later know why the file is gone.
187
- notes.push(
188
- `full output: ${snapshot.spillPath} ` +
189
- `(deleted in ~${Math.round(COMPLETED_RETENTION_MS / 60_000)} min)`,
190
- );
191
- } else {
192
- notes.push(
193
- `full output could NOT be saved, unrecoverable: ${snapshot.spillError ?? "unknown"}`,
194
- );
195
- }
196
- return `${label} (${notes.join(" · ")}):\n${body}`;
197
- }
198
-
199
- /** Render the shared header+output body used by every injected message. */
200
- function buildOutputMessage(proc: BgProc, header: string): string {
201
- const parts: string[] = [];
202
- const stdout = describeStream("stdout", proc.stdout.snapshot());
203
- const stderr = describeStream("stderr", proc.stderr.snapshot());
204
- if (stdout) parts.push(stdout);
205
- if (stderr) parts.push(stderr);
206
- const output = parts.join("\n") || "(no output)";
207
- // English on purpose: this text is injected into the model's context, and
208
- // every other model-facing string in this server — the tool descriptions it
209
- // sits alongside — is English. It is also cheaper in tokens.
210
- return (
211
- `${header}\n` +
212
- `command: ${proc.command.slice(0, 200)}\n` +
213
- `exit code: ${proc.exitCode ?? "unknown"}\n` +
214
- output
215
- );
216
- }
217
-
218
- /**
219
- * Deliver one turn to the topic's inbox.
220
- *
221
- * Returns false when the inbox write failed. Every caller promises the
222
- * caller it need not poll, so a dropped delivery is the one failure that
223
- * leaves a background job silently unfinished — treat it as not-done.
224
- */
225
- function injectMessage(proc: BgProc, message: string, label: string): boolean {
226
- // `proc.topic` is the canonical topic id (see mcp-config background-bash
227
- // build: `topicId ?? session`). Route through the shared helper so the
228
- // filename is the `topic-id-{base64url}` form the session-inbox worker
229
- // decodes back to a topic id — a bare `${topic}.jsonl` is misread as a
230
- // topic *title* and silently dropped when no topic has that title.
231
- try {
232
- appendJsonlEntry(sessionInboxPath(proc.userId, proc.topic), {
233
- type: "tell",
234
- from: "__bg_bash__",
235
- message,
236
- depth: 0,
237
- timestamp: new Date().toISOString(),
238
- });
239
- process.stderr.write(`[bg-bash] injected ${label} ${proc.bashId} exit=${proc.exitCode}\n`);
240
- return true;
241
- } catch (e) {
242
- process.stderr.write(
243
- `[bg-bash] FAILED to deliver ${label} ${proc.bashId} (exit=${proc.exitCode}): ${e}\n` +
244
- `[bg-bash] the topic will never be told this job finished; ` +
245
- `its output is kept at ${spillDir(proc.bashId)}\n`,
246
- );
247
- return false;
248
- }
249
- }
250
-
251
- function injectCompletion(proc: BgProc): boolean {
252
- return injectMessage(
253
- proc,
254
- buildOutputMessage(proc, `[background_bash ${proc.bashId} finished]`),
255
- "completion",
256
- );
257
- }
258
-
259
- /**
260
- * Test newly arrived bytes for `watch.regex`, line by line, carrying any
261
- * trailing partial line to the next chunk so a match split across two reads
262
- * is never missed. Returns the first matching line, if any.
263
- */
264
- function checkWatchMatch(
265
- proc: BgProc,
266
- stream: "stdout" | "stderr",
267
- chunk: Buffer,
268
- ): string | undefined {
269
- const watch = proc.watch;
270
- if (!watch || watch.matched) return undefined;
271
- if (watch.target !== "both" && watch.target !== stream) return undefined;
272
- const carryKey = stream === "stdout" ? "stdoutCarry" : "stderrCarry";
273
- const lines = (watch[carryKey] + chunk.toString("utf8")).split("\n");
274
- watch[carryKey] = lines.pop() ?? "";
275
- for (const line of lines) {
276
- if (watch.regex.test(line)) return line;
277
- }
278
- return undefined;
279
- }
280
-
281
- /** A watch line matched: deliver the one promised turn and stop the process. */
282
- function handleWatchMatch(proc: BgProc, line: string): void {
283
- const watch = proc.watch;
284
- if (!watch || watch.matched) return;
285
- watch.matched = true;
286
- watch.finished = true;
287
- if (watch.timeoutTimer) {
288
- clearTimeout(watch.timeoutTimer);
289
- watch.timeoutTimer = undefined;
290
- }
291
- const header = `[background_bash_watch ${proc.bashId} matched]\nmatched line: ${line.slice(0, 500)}`;
292
- proc.delivered = injectMessage(proc, buildOutputMessage(proc, header), "watch match");
293
- terminateProc(proc);
294
- }
295
-
296
- /** No line matched within the deadline: deliver a timeout turn and stop. */
297
- function handleWatchTimeout(proc: BgProc): void {
298
- const watch = proc.watch;
299
- if (!watch || watch.finished) return;
300
- watch.finished = true;
301
- const header = `[background_bash_watch ${proc.bashId} timed out without a match]`;
302
- proc.delivered = injectMessage(proc, buildOutputMessage(proc, header), "watch timeout");
303
- terminateProc(proc);
304
- }
305
-
306
- function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals): boolean {
307
- if (!child.pid) return child.kill(signal);
308
- try {
309
- process.kill(-child.pid, signal);
310
- return true;
311
- } catch {
312
- try {
313
- process.kill(child.pid, signal);
314
- return true;
315
- } catch {
316
- return false;
317
- }
318
- }
319
- }
320
-
321
- function forgetProc(proc: BgProc): void {
322
- if (procs.get(proc.bashId) !== proc) return;
323
- if (proc.cleanupTimer) clearTimeout(proc.cleanupTimer);
324
- procs.delete(proc.bashId);
325
- // The spill is only reachable through this registry entry, so its lifetime
326
- // is the completed-process retention lifetime.
327
- proc.stdout.close();
328
- proc.stderr.close();
329
- removeSpill(proc.bashId);
330
- }
331
-
332
- function pruneCompletedProcs(): void {
333
- // A burst of short high-output jobs could otherwise evict a spill seconds
334
- // after its completion turn was queued but before the topic consumed it.
335
- const evictableBefore = Date.now() - PRUNE_GRACE_MS;
336
- const completed = [...procs.values()]
337
- // Undelivered jobs hold the only copy of output the topic never saw.
338
- .filter((proc) => proc.exited && proc.delivered && proc.startedAt < evictableBefore)
339
- .sort((a, b) => a.startedAt - b.startedAt);
340
- while (completed.length > MAX_COMPLETED_PROCS) {
341
- const oldest = completed.shift();
342
- if (oldest) forgetProc(oldest);
343
- }
344
- }
345
-
346
- function terminateProc(proc: BgProc): boolean {
347
- if (proc.exited) return false;
348
- const signalled = signalProcessTree(proc.child, "SIGTERM");
349
- if (!proc.killTimer) {
350
- proc.killTimer = setTimeout(() => {
351
- if (!proc.exited) signalProcessTree(proc.child, "SIGKILL");
352
- }, SIGTERM_GRACE_MS);
353
- proc.killTimer.unref?.();
354
- }
355
- return signalled;
356
- }
357
-
358
- function finishProc(proc: BgProc, exitCode: number | null): void {
359
- if (proc.exited) return;
360
- proc.exited = true;
361
- proc.exitCode = exitCode;
362
- if (proc.killTimer) {
363
- clearTimeout(proc.killTimer);
364
- proc.killTimer = undefined;
365
- }
366
- // Release the spill descriptors before the completion turn quotes their
367
- // paths, so anything reading them sees a closed, complete file.
368
- proc.stdout.close();
369
- proc.stderr.close();
370
- if (proc.watch) {
371
- if (proc.watch.timeoutTimer) {
372
- clearTimeout(proc.watch.timeoutTimer);
373
- proc.watch.timeoutTimer = undefined;
374
- }
375
- if (!proc.watch.finished) {
376
- // The command exited on its own — no match, no timeout yet. This is
377
- // still exactly one delivered turn, same one-shot guarantee.
378
- proc.watch.finished = true;
379
- const header = `[background_bash_watch ${proc.bashId} exited before matching]`;
380
- proc.delivered = injectMessage(proc, buildOutputMessage(proc, header), "watch exit");
381
- }
382
- // Else: a match or timeout already delivered the one promised turn
383
- // (`proc.delivered` was set there) — do not inject a second one.
384
- } else {
385
- proc.delivered = injectCompletion(proc);
386
- }
387
- if (proc.delivered) {
388
- proc.cleanupTimer = setTimeout(() => forgetProc(proc), COMPLETED_RETENTION_MS);
389
- proc.cleanupTimer.unref?.();
390
- }
391
- // An undelivered job keeps its registry entry and spill: `background_bash_
392
- // output` is the only way left to retrieve it, and expiring it on the normal
393
- // schedule would destroy the sole copy of output nobody has seen.
394
- pruneCompletedProcs();
395
- }
396
-
397
- interface WatchConfig {
398
- regex: RegExp;
399
- target: "stdout" | "stderr" | "both";
400
- timeoutSeconds: number;
401
- }
402
-
403
- function spawnBash(
404
- context: BgContext,
405
- command: string,
406
- cwd?: string,
407
- watchConfig?: WatchConfig,
408
- ): { bashId: string } | { error: string } {
409
- if (!command.trim()) return { error: "empty command" };
410
- const bashId = newBashId();
411
- let child: ChildProcess;
412
- try {
413
- child = spawn("bash", ["-c", command], {
414
- ...(cwd ? { cwd } : {}),
415
- detached: true,
416
- env: process.env,
417
- });
418
- } catch (e) {
419
- return { error: `spawn failed: ${e instanceof Error ? e.message : String(e)}` };
420
- }
421
- const dir = spillDir(bashId);
422
- const proc: BgProc = {
423
- bashId,
424
- userId: context.userId,
425
- topic: context.topic,
426
- command,
427
- child,
428
- stdout: new BoundedOutputStream({
429
- maxBytes: MAX_OUTPUT_BYTES,
430
- spillPath: join(dir, "stdout.log"),
431
- }),
432
- stderr: new BoundedOutputStream({
433
- maxBytes: MAX_OUTPUT_BYTES,
434
- spillPath: join(dir, "stderr.log"),
435
- }),
436
- stdoutCursor: 0,
437
- stderrCursor: 0,
438
- exited: false,
439
- exitCode: null,
440
- delivered: false,
441
- startedAt: Date.now(),
442
- watch: watchConfig
443
- ? {
444
- regex: watchConfig.regex,
445
- target: watchConfig.target,
446
- matched: false,
447
- finished: false,
448
- stdoutCarry: "",
449
- stderrCarry: "",
450
- }
451
- : undefined,
452
- };
453
- // Raw Buffers: decoding per chunk would split multi-byte characters that
454
- // straddle a chunk boundary. The buffer decodes at read time instead.
455
- child.stdout?.on("data", (c: Buffer) => {
456
- proc.stdout.append(c);
457
- const line = checkWatchMatch(proc, "stdout", c);
458
- if (line !== undefined) handleWatchMatch(proc, line);
459
- });
460
- child.stderr?.on("data", (c: Buffer) => {
461
- proc.stderr.append(c);
462
- const line = checkWatchMatch(proc, "stderr", c);
463
- if (line !== undefined) handleWatchMatch(proc, line);
464
- });
465
- child.on("close", (code) => {
466
- finishProc(proc, code);
467
- });
468
- child.on("error", (err) => {
469
- proc.stderr.append(Buffer.from(`[spawn error]: ${err.message}\n`, "utf-8"));
470
- finishProc(proc, -1);
471
- });
472
- procs.set(bashId, proc);
473
- if (proc.watch) {
474
- proc.watch.timeoutTimer = setTimeout(
475
- () => handleWatchTimeout(proc),
476
- watchConfig!.timeoutSeconds * 1_000,
477
- );
478
- proc.watch.timeoutTimer.unref?.();
479
- }
480
- process.stderr.write(`[bg-bash] started ${bashId}: ${command.slice(0, 80)}\n`);
481
- return { bashId };
482
- }
483
-
484
- process.on("exit", () => {
485
- for (const proc of procs.values()) {
486
- if (!proc.exited) signalProcessTree(proc.child, "SIGKILL");
487
- }
488
- });
489
-
490
- // --- MCP tool factory (shared registry via closure, one instance per connection) ---
491
-
492
- function buildMcpServer(context: BgContext): McpServer {
493
- const server = new McpServer({ name: "background-bash", version: "1.0.0" });
494
-
495
- server.tool(
496
- "background_bash_run",
497
- [
498
- "Start a long-running shell command in the background. Returns bash_id immediately.",
499
- "Use this only for independent commands expected to run longer than about 2 minutes or survive beyond the current agent turn.",
500
- "Run ordinary builds, tests, and commands whose result is needed for the next step in the foreground; do not use this merely to avoid waiting.",
501
- "The process runs independently of this agent turn.",
502
- "When it exits, its output is injected into this session as a new turn.",
503
- `Each stream is previewed up to ${Math.floor(MAX_OUTPUT_BYTES / 1024)} KiB (head + tail);`,
504
- "when output exceeds that, the preview states how much was omitted and gives the path of a",
505
- "spill file holding the complete stdout/stderr, readable until the process is pruned.",
506
- "You do NOT need to poll for completion — just start it and continue.",
507
- "Use background_bash_output to peek at live output, background_bash_kill to terminate early.",
508
- ].join(" "),
509
- {
510
- command: z.string().describe("Shell command (executed via bash -c)"),
511
- cwd: z.string().optional().describe("Working directory (absolute path)"),
512
- },
513
- async ({ command, cwd }) => {
514
- const result = spawnBash(context, command, cwd);
515
- if ("error" in result) return mcpError(result.error);
516
- return mcpOk(JSON.stringify({ bash_id: result.bashId, status: "started" }));
517
- },
518
- );
519
-
520
- server.tool(
521
- "background_bash_output",
522
- [
523
- "Poll incremental stdout/stderr since the last call. Returns only new bytes plus exited/exitCode.",
524
- "stdoutDropped/stderrDropped count bytes that scrolled out of the live window before this call",
525
- "reached them; read stdoutPath/stderrPath for the complete output when that happens.",
526
- ].join(" "),
527
- { bash_id: z.string().describe("bash_id from background_bash_run") },
528
- async ({ bash_id }) => {
529
- const proc = procs.get(bash_id);
530
- if (!proc || !sameContext(proc, context)) return mcpError(`Unknown bash_id: ${bash_id}`);
531
- const out = proc.stdout.readSince(proc.stdoutCursor);
532
- const err = proc.stderr.readSince(proc.stderrCursor);
533
- proc.stdoutCursor = out.nextCursor;
534
- proc.stderrCursor = err.nextCursor;
535
- return mcpOk(
536
- JSON.stringify({
537
- bash_id,
538
- exited: proc.exited,
539
- exitCode: proc.exitCode,
540
- stdout: out.text,
541
- stderr: err.text,
542
- ...(out.droppedBytes ? { stdoutDropped: out.droppedBytes } : {}),
543
- ...(err.droppedBytes ? { stderrDropped: err.droppedBytes } : {}),
544
- ...(proc.stdout.spillPath ? { stdoutPath: proc.stdout.spillPath } : {}),
545
- ...(proc.stderr.spillPath ? { stderrPath: proc.stderr.spillPath } : {}),
546
- }),
547
- );
548
- },
549
- );
550
-
551
- server.tool(
552
- "background_bash_watch",
553
- [
554
- "Start a background shell command and watch its stdout/stderr for a regex match, one line at a time.",
555
- "The moment a line matches `match`, the process is stopped and the matching line plus buffered",
556
- "output is injected into this session as a new turn — you do NOT need to poll.",
557
- "If nothing matches within `timeout_seconds` (default 3600), or the command exits on its own first,",
558
- "a final status turn is injected instead. Exactly one turn is ever injected per watch — this is",
559
- "one-shot only, there is no repeat/streaming mode yet.",
560
- "Prefer this over `background_bash_run` + manual `background_bash_output` polling when you are",
561
- "waiting for a specific condition to appear (a deploy readiness line, an error) rather than for",
562
- "the command itself to finish.",
563
- ].join(" "),
564
- {
565
- command: z.string().describe("Shell command (executed via bash -c)"),
566
- match: z.string().describe("Regular expression tested against each output line"),
567
- cwd: z.string().optional().describe("Working directory (absolute path)"),
568
- stream: z
569
- .enum(["stdout", "stderr", "both"])
570
- .optional()
571
- .describe("Which stream(s) to test against `match` (default both)"),
572
- timeout_seconds: z
573
- .number()
574
- .int()
575
- .positive()
576
- .max(MAX_WATCH_TIMEOUT_SECONDS)
577
- .optional()
578
- .describe(
579
- `Give up waiting for a match after this many seconds (default ${DEFAULT_WATCH_TIMEOUT_SECONDS}, max ${MAX_WATCH_TIMEOUT_SECONDS})`,
580
- ),
581
- },
582
- async ({ command, match, cwd, stream, timeout_seconds }) => {
583
- let regex: RegExp;
584
- try {
585
- regex = new RegExp(match);
586
- } catch (e) {
587
- return mcpError(
588
- `invalid regex in \`match\`: ${e instanceof Error ? e.message : String(e)}`,
589
- );
590
- }
591
- const result = spawnBash(context, command, cwd, {
592
- regex,
593
- target: stream ?? "both",
594
- timeoutSeconds: timeout_seconds ?? DEFAULT_WATCH_TIMEOUT_SECONDS,
595
- });
596
- if ("error" in result) return mcpError(result.error);
597
- return mcpOk(JSON.stringify({ bash_id: result.bashId, status: "watching" }));
598
- },
599
- );
600
-
601
- server.tool(
602
- "background_bash_kill",
603
- "Terminate a background process (SIGTERM → SIGKILL after 5s). Idempotent.",
604
- { bash_id: z.string().describe("bash_id to kill") },
605
- async ({ bash_id }) => {
606
- const proc = procs.get(bash_id);
607
- if (!proc || !sameContext(proc, context)) return mcpError(`Unknown bash_id: ${bash_id}`);
608
- if (proc.exited)
609
- return mcpOk(JSON.stringify({ bash_id, alreadyExited: true, exitCode: proc.exitCode }));
610
- terminateProc(proc);
611
- return mcpOk(JSON.stringify({ bash_id, killed: true }));
612
- },
613
- );
614
-
615
- return server;
616
- }
617
-
618
- // --- HTTP server ---
619
-
620
- // SSE: track active transports so POST /message can route back to them
621
- const sseTransports = new Map<string, SSEServerTransport>();
622
- const streamableTransports = new Map<string, StreamableHTTPServerTransport>();
623
-
624
- function firstHeader(value: string | string[] | undefined): string {
625
- if (Array.isArray(value)) return value[0] ?? "";
626
- return value ?? "";
627
- }
628
-
629
- function writeJsonError(res: ServerResponse, status: number, message: string): void {
630
- res.writeHead(status, { "content-type": "application/json" }).end(
631
- JSON.stringify({
632
- jsonrpc: "2.0",
633
- error: { code: -32000, message },
634
- id: null,
635
- }),
636
- );
637
- }
638
-
639
- function requestContext(req: IncomingMessage, url: URL): BgContext | null {
640
- const userId =
641
- firstHeader(req.headers["x-background-bash-user"]) || url.searchParams.get("user") || "";
642
- const topic =
643
- firstHeader(req.headers["x-background-bash-topic"]) || url.searchParams.get("topic") || "";
644
- const capability =
645
- firstHeader(req.headers["x-background-bash-capability"]) ||
646
- url.searchParams.get("capability") ||
647
- "";
648
- if (!userId || !topic || !capability) return null;
649
- const context = { userId, topic };
650
- const expected = contextCapability(context);
651
- const actualBytes = Buffer.from(capability);
652
- const expectedBytes = Buffer.from(expected);
653
- if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes))
654
- return null;
655
- return context;
656
- }
657
-
658
- const httpServer = createServer(async (req, res) => {
659
- const urlStr = req.url ?? "/";
660
- const url = new URL(urlStr, `http://127.0.0.1:${port}`);
661
-
662
- // Health probe
663
- if (req.method === "GET" && url.pathname === "/health") {
664
- res.writeHead(200, { "content-type": "text/plain" }).end(runtimeServerId);
665
- return;
666
- }
667
-
668
- if (req.method === "DELETE" && url.pathname === "/contexts") {
669
- const context = requestContext(req, url);
670
- if (!context) {
671
- res.writeHead(403).end("forbidden");
672
- return;
673
- }
674
- let killed = 0;
675
- for (const proc of procs.values()) {
676
- if (!proc.exited && sameContext(proc, context) && terminateProc(proc)) killed++;
677
- }
678
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ killed }));
679
- return;
680
- }
681
-
682
- // SSE endpoint (claude / maestro)
683
- if (req.method === "GET" && url.pathname === "/sse") {
684
- const context = requestContext(req, url);
685
- if (!context) {
686
- res.writeHead(403).end("forbidden");
687
- return;
688
- }
689
- const transport = new SSEServerTransport("/message", res);
690
- const server = buildMcpServer(context);
691
- sseTransports.set(transport.sessionId, transport);
692
- transport.onclose = () => sseTransports.delete(transport.sessionId);
693
- await server.connect(transport);
694
- return;
695
- }
696
-
697
- // SSE POST message handler
698
- if (req.method === "POST" && url.pathname === "/message") {
699
- const sessionId = url.searchParams.get("sessionId") ?? "";
700
- const transport = sseTransports.get(sessionId);
701
- if (!transport) {
702
- res.writeHead(404).end("session not found");
703
- return;
704
- }
705
- await transport.handlePostMessage(req, res);
706
- return;
707
- }
708
-
709
- // Streamable HTTP endpoint (codex)
710
- if (url.pathname === "/mcp") {
711
- const sessionId = firstHeader(req.headers["mcp-session-id"]);
712
- let transport: StreamableHTTPServerTransport | undefined;
713
-
714
- if (sessionId) {
715
- transport = streamableTransports.get(sessionId);
716
- if (!transport) {
717
- writeJsonError(res, 404, "Session not found");
718
- return;
719
- }
720
- } else if (req.method === "POST") {
721
- const context = requestContext(req, url);
722
- if (!context) {
723
- writeJsonError(res, 403, "Forbidden");
724
- return;
725
- }
726
- transport = new StreamableHTTPServerTransport({
727
- sessionIdGenerator: () => randomUUID(),
728
- onsessioninitialized: (id) => {
729
- if (transport) streamableTransports.set(id, transport);
730
- },
731
- });
732
- transport.onclose = () => {
733
- const id = transport?.sessionId;
734
- if (id) streamableTransports.delete(id);
735
- };
736
- transport.onerror = (err) => {
737
- process.stderr.write(`[bg-bash] streamable transport error: ${err.message}\n`);
738
- };
739
- const server = buildMcpServer(context);
740
- await server.connect(transport);
741
- } else {
742
- writeJsonError(res, 400, "Mcp-Session-Id header is required");
743
- return;
744
- }
745
-
746
- await transport.handleRequest(req, res);
747
- return;
748
- }
749
-
750
- res.writeHead(404).end();
751
- });
752
-
753
- httpServer.listen(port, "127.0.0.1", () => {
754
- sweepStaleSpills();
755
- process.stderr.write(`[bg-bash] shared runtime listening on 127.0.0.1:${port}\n`);
756
- });