negotium 0.2.12 → 0.2.14

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 (41) hide show
  1. package/dist/agent-helpers.js +34 -8
  2. package/dist/agent-helpers.js.map +6 -6
  3. package/dist/background-bash.js +32 -6
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +14 -2
  6. package/dist/browser-runtime.js.map +3 -3
  7. package/dist/{chunk-8asy22vc.js → chunk-5g5a63vv.js} +32 -6
  8. package/dist/{chunk-8asy22vc.js.map → chunk-5g5a63vv.js.map} +4 -4
  9. package/dist/hosted-agent.js +33 -7
  10. package/dist/hosted-agent.js.map +5 -5
  11. package/dist/main.js +603 -353
  12. package/dist/main.js.map +12 -11
  13. package/dist/mcp-factories.js +34 -8
  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 +14 -2
  18. package/dist/prompts.js.map +3 -3
  19. package/dist/query-runtime.js +14 -2
  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 +45 -5
  27. package/dist/runtime/src/platform/config.ts +35 -2
  28. package/dist/runtime/src/runtime/bashrs-completions.ts +328 -0
  29. package/dist/runtime/src/runtime/turn-runner.ts +6 -0
  30. package/dist/runtime/src/version.ts +1 -1
  31. package/dist/runtime-helpers.js +14 -2
  32. package/dist/runtime-helpers.js.map +3 -3
  33. package/dist/types/apps/negotium/src/mcp-servers.d.ts +16 -3
  34. package/dist/types/packages/core/src/platform/background-bash/manager.d.ts +6 -1
  35. package/dist/types/packages/core/src/platform/config.d.ts +21 -1
  36. package/dist/types/packages/core/src/version.d.ts +1 -1
  37. package/dist/vault.js +14 -2
  38. package/dist/vault.js.map +3 -3
  39. package/install-bash-rs.mjs +77 -0
  40. package/package.json +3 -2
  41. package/dist/runtime/src/mcp/background-bash-server.ts +0 -756
@@ -1,6 +1,12 @@
1
1
  import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { BACKGROUND_BASH_SERVER, BG_BASH_BASE_PORT, BG_BASH_MAX_PORT } from "#platform/config";
3
+ import {
4
+ BASH_RS_BIN,
5
+ BASH_RS_VERSION,
6
+ BASHRS_SPILL_ROOT,
7
+ BG_BASH_BASE_PORT,
8
+ BG_BASH_MAX_PORT,
9
+ } from "#platform/config";
4
10
  import { delay } from "#platform/delay";
5
11
  import { logger } from "#platform/logger";
6
12
  import { deriveBgBashContextCapability } from "./context";
@@ -25,7 +31,12 @@ export interface BackgroundBashManager {
25
31
  }
26
32
 
27
33
  export interface BackgroundBashManagerOptions {
28
- serverFile?: string;
34
+ /**
35
+ * bash-rs executable. Defaults to the installed binary; injectable so a test
36
+ * can drive the manager's bookkeeping with a fake `spawn` on a host that has
37
+ * no binary at all.
38
+ */
39
+ bashRsBin?: string;
29
40
  basePort?: number;
30
41
  maxPort?: number;
31
42
  capability?: string;
@@ -65,7 +76,7 @@ export function createBackgroundBashManager(
65
76
  const knownContexts = new Map<string, { userId: string; topic: string }>();
66
77
  const runtimeCapability = options.capability ?? randomBytes(32).toString("hex");
67
78
  const runtimeServerId = options.serverId ?? randomBytes(16).toString("hex");
68
- const serverFile = options.serverFile ?? BACKGROUND_BASH_SERVER;
79
+ const bashRsBin = options.bashRsBin ?? BASH_RS_BIN;
69
80
  const basePort = options.basePort ?? BG_BASH_BASE_PORT;
70
81
  const maxPort = options.maxPort ?? BG_BASH_MAX_PORT;
71
82
  const fetchImpl = options.fetch ?? globalThis.fetch;
@@ -103,7 +114,18 @@ export function createBackgroundBashManager(
103
114
  const response = await fetchImpl(`http://127.0.0.1:${port}/health`, {
104
115
  signal: AbortSignal.timeout(2000),
105
116
  });
106
- return response.ok && (await response.text()) === runtimeServerId;
117
+ if (!response.ok) return false;
118
+ // Confirm this port answers for *our* spawned process rather than a
119
+ // stale one left behind by a previous manager instance.
120
+ const body = await response.text();
121
+ try {
122
+ const parsed = JSON.parse(body);
123
+ return (
124
+ Boolean(parsed) && typeof parsed === "object" && parsed.instance_id === runtimeServerId
125
+ );
126
+ } catch {
127
+ return false;
128
+ }
107
129
  } catch {
108
130
  return false;
109
131
  }
@@ -126,14 +148,32 @@ export function createBackgroundBashManager(
126
148
  reservedPort?: number,
127
149
  excludedPorts: ReadonlySet<number> = new Set(),
128
150
  ): Promise<number> {
151
+ // bash-rs is the only implementation. A TS server used to stand in when the
152
+ // binary was absent, but the two agreed on the HTTP protocol while exposing
153
+ // different MCP tool names (bash_run vs background_bash_run), so which one
154
+ // spawned silently changed the tool surface an agent saw. Failing loudly
155
+ // beats serving a different API than the caller was told to expect.
156
+ //
157
+ // Checked before allocating: the caller retries once per turn, and a port
158
+ // reserved by a throw is never released, so allocating first leaked one
159
+ // port per attempt until the range was exhausted and the real cause was
160
+ // buried under "No available ports".
161
+ if (!bashRsBin) {
162
+ throw new Error(
163
+ `background-bash requires the bash-rs ${BASH_RS_VERSION} binary; run install-bash-rs.mjs (or set NEGOTIUM_BASH_RS_BIN)`,
164
+ );
165
+ }
129
166
  const port = reservedPort ?? (await allocatePort(excludedPorts));
130
- const process = spawnImpl("bun", ["run", serverFile, `--port=${port}`], {
167
+ const process = spawnImpl(bashRsBin, [String(port)], {
131
168
  stdio: "ignore",
132
169
  detached: false,
133
170
  env: {
134
171
  ...(options.env ?? globalThis.process.env),
135
172
  NEGOTIUM_BG_BASH_CAPABILITY: runtimeCapability,
136
173
  NEGOTIUM_BG_BASH_SERVER_ID: runtimeServerId,
174
+ BASHRS_HTTP_CAPABILITY: runtimeCapability,
175
+ BASHRS_INSTANCE_ID: runtimeServerId,
176
+ BASHRS_SPILL_ROOT,
137
177
  },
138
178
  });
139
179
 
@@ -167,6 +167,33 @@ export const SNIPPETS_API_URL = (
167
167
 
168
168
  export const BROWSER_RS_BIN = resolveBrowserRsBin(envText("NEGOTIUM_BROWSER_RS_BIN"));
169
169
 
170
+ /** bash-rs release tested with this Negotium version — see apps/negotium/install-bash-rs.mjs. */
171
+ export const BASH_RS_VERSION = "v0.1.5";
172
+
173
+ /**
174
+ * Resolve the bash-rs binary the same way `resolveBrowserRsBin` resolves
175
+ * Browser.rs: a versioned private location, no PATH lookup, `undefined`
176
+ * (rather than throwing) when it's missing. There is no substitute: the manager
177
+ * throws when a turn tries to prepare background-bash without a binary (see
178
+ * `background-bash/manager.ts`), the turn runner catches that and continues
179
+ * with a reminder that the tools are gone, so background-bash is simply
180
+ * unavailable on a platform without a prebuilt binary.
181
+ */
182
+ export function resolveBashRsBin(envValue?: string): string | undefined {
183
+ const override = envValue?.trim();
184
+ const candidate = override
185
+ ? resolve(override)
186
+ : resolve(BINARIES_DIR, "bash-rs", BASH_RS_VERSION, "bash-rs");
187
+ try {
188
+ accessSync(candidate, constants.X_OK);
189
+ return candidate;
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ }
194
+
195
+ export const BASH_RS_BIN = resolveBashRsBin(envText("NEGOTIUM_BASH_RS_BIN"));
196
+
170
197
  // Managed Browser.rs terminates MCP transports and security policy itself.
171
198
  export function resolveBrowserMcpBin(envValue?: string): string {
172
199
  const override = envValue?.trim();
@@ -257,8 +284,6 @@ export const SYSTEM_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/system-health
257
284
 
258
285
  export const AGENT_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/agent-health-server.ts");
259
286
 
260
- export const BACKGROUND_BASH_SERVER = resolve(PROJECT_ROOT, "src/mcp/background-bash-server.ts");
261
-
262
287
  export const VAULT_SERVER = resolve(PROJECT_ROOT, "src/mcp/vault-server.ts");
263
288
 
264
289
  export const BG_BASH_BASE_PORT = parsePortEnv(process.env.BG_BASH_BASE_PORT, 9700);
@@ -357,6 +382,14 @@ export const DM_CMD_DIR = resolve(RUN_DIR, "dm-commands");
357
382
  export const DM_RESP_DIR = resolve(RUN_DIR, "dm-responses");
358
383
  export const SESSION_INBOX_DIR = resolve(RUN_DIR, "session-inbox");
359
384
  export const SESSION_ASKS_DIR = resolve(RUN_DIR, "session-asks");
385
+ /**
386
+ * Passed to `bash-rs` as `BASHRS_SPILL_ROOT`. Each job gets a subdirectory
387
+ * here (`{bash_id}/meta.json`, `result.json`, `stdout.log`, `stderr.log`) —
388
+ * see bash-rs-mcp's `journal.rs`. `runtime/bashrs-completions.ts` watches
389
+ * this directory and turns `result.json` into a session-inbox `tell`. The
390
+ * retired TypeScript server used to write one directly instead.
391
+ */
392
+ export const BASHRS_SPILL_ROOT = resolve(RUN_DIR, "bashrs");
360
393
  export const PLAYWRIGHT_BASE_PORT = parsePortEnv(process.env.PLAYWRIGHT_BASE_PORT, 9100);
361
394
  export const PLAYWRIGHT_MAX_PORT = parsePortEnv(process.env.PLAYWRIGHT_MAX_PORT, 9499);
362
395
  export const PLAYWRIGHT_PORTS_DIR = resolve(RUN_DIR, "playwright-ports");
@@ -0,0 +1,328 @@
1
+ /**
2
+ * bash-rs completion watcher — turns `result.json` files written by the Rust
3
+ * `bash-rs` background-bash daemon into session-inbox `tell`s. bash-rs is the
4
+ * only backend; the retired TypeScript server called `injectMessage()` itself.
5
+ *
6
+ * Why a watcher instead of bash-rs calling back into negotium: bash-rs is a
7
+ * standalone project (github.com/maestrojeong/bash-rs-mcp, sibling of
8
+ * browser-rs-mcp) that knows nothing about topics/session-inbox/turns — it
9
+ * only ever appends `{bash_id}/meta.json` (on spawn) and `{bash_id}/result.json`
10
+ * (on completion) under `BASHRS_SPILL_ROOT`, and never reads or deletes
11
+ * either. Any consumer, negotium or otherwise, watches that directory the
12
+ * same way `runtime/inbox.ts` already watches the session-inbox itself:
13
+ * `fs.watch` + a fallback poll, at-least-once, dedup by a stable id.
14
+ *
15
+ * `owner` on the wire is `${userId}\0${topicId}` — see
16
+ * `platform/mcp-config.ts::backgroundBashTransport` for what negotium sends
17
+ * as `X-Background-Bash-User`/`X-Background-Bash-Topic`, and bash-rs's own
18
+ * `security.rs::resolve_identity` for the matching NUL-join on the other end.
19
+ */
20
+
21
+ import {
22
+ closeSync,
23
+ openSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ readSync,
27
+ renameSync,
28
+ rmSync,
29
+ statSync,
30
+ } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { debouncedFlush, FALLBACK_INTERVAL_MS, watchDir } from "#outbox/utils";
33
+ import { BASHRS_SPILL_ROOT } from "#platform/config";
34
+ import { appendJsonlEntry } from "#platform/jsonl";
35
+ import { logger } from "#platform/logger";
36
+ import { sessionInboxPath } from "#query/session-inbox-path";
37
+ import {
38
+ acquireRuntimeProcessLease,
39
+ PROCESS_LEASE_HEARTBEAT_MS,
40
+ type RuntimeProcessLeaseHandle,
41
+ } from "#storage/runtime-process-leases";
42
+
43
+ /**
44
+ * Where a finished background job's turn is delivered.
45
+ *
46
+ * The default appends to negotium's own session inbox. An embedding host that
47
+ * owns a different inbox installs its own sink here — otium, for one, resolves
48
+ * `RUN_DIR` to its own state dir and runs its own inbox worker, so writing to
49
+ * negotium's path would drop the turn on the floor rather than deliver it.
50
+ * Mirrors the `setFileHooks()` seam in `runtime/file-hooks.ts`.
51
+ *
52
+ * A sink must throw to signal failure: the caller leaves `result.json` in
53
+ * place so the next sweep retries, which is what keeps delivery at-least-once.
54
+ */
55
+ export interface BashrsCompletion {
56
+ userId: string;
57
+ topicId: string;
58
+ /** Stable per-job id; use it to collapse a retried delivery into one turn. */
59
+ bashId: string;
60
+ message: string;
61
+ }
62
+
63
+ export type BashrsCompletionSink = (completion: BashrsCompletion) => void;
64
+
65
+ const defaultSink: BashrsCompletionSink = ({ userId, topicId, bashId, message }) => {
66
+ appendJsonlEntry(sessionInboxPath(userId, topicId), {
67
+ type: "tell",
68
+ from: "__bg_bash__",
69
+ message,
70
+ depth: 0,
71
+ requestId: bashId,
72
+ timestamp: new Date().toISOString(),
73
+ });
74
+ };
75
+
76
+ let completionSink: BashrsCompletionSink = defaultSink;
77
+
78
+ /** Install a host's delivery sink, or pass null to restore the default. */
79
+ export function setBashrsCompletionSink(sink: BashrsCompletionSink | null): void {
80
+ completionSink = sink ?? defaultSink;
81
+ }
82
+
83
+ const PROCESS_ROLE = "worker:bashrs-completions";
84
+ /** How long a delivered job's spill dir (logs + result.json.injected) is kept
85
+ * around before being swept — mirrors the TS server's own
86
+ * `COMPLETED_RETENTION_MS`, for the same reason: a reader might still be
87
+ * mid-way through fetching a spill path out of the injected message. */
88
+ const RETENTION_MS = 15 * 60_000;
89
+ /** Read at most this many trailing bytes of stdout.log/stderr.log into the
90
+ * injected message — full output stays on disk at the path we report. */
91
+ const TAIL_BYTES = 8 * 1024;
92
+
93
+ interface JobResult {
94
+ bash_id: string;
95
+ owner: string;
96
+ exit_code: number | null;
97
+ finished_at_ms: number;
98
+ matched_line: string | null;
99
+ /**
100
+ * Which arm ended a watch. Absent for a plain background run, and absent
101
+ * from results written by bash-rs before v0.1.4 — those fall back to the
102
+ * generic completion notice.
103
+ */
104
+ watch_outcome?: "matched" | "timeout" | "exited";
105
+ unknown: boolean;
106
+ }
107
+
108
+ function isJobResult(value: unknown): value is JobResult {
109
+ if (!value || typeof value !== "object") return false;
110
+ const v = value as Record<string, unknown>;
111
+ return typeof v.bash_id === "string" && typeof v.owner === "string";
112
+ }
113
+
114
+ function readTail(filePath: string): { text: string; truncated: boolean } {
115
+ try {
116
+ const { size } = statSync(filePath);
117
+ const fd = openSync(filePath, "r");
118
+ try {
119
+ const start = Math.max(0, size - TAIL_BYTES);
120
+ const buf = Buffer.alloc(size - start);
121
+ readSync(fd, buf, 0, buf.length, start);
122
+ return { text: buf.toString("utf8"), truncated: start > 0 };
123
+ } finally {
124
+ closeSync(fd);
125
+ }
126
+ } catch {
127
+ return { text: "", truncated: false };
128
+ }
129
+ }
130
+
131
+ function buildMessage(dir: string, result: JobResult): string {
132
+ const stdout = readTail(join(dir, "stdout.log"));
133
+ const stderr = readTail(join(dir, "stderr.log"));
134
+ const parts: string[] = [];
135
+ if (stdout.text.trim() || stdout.truncated) {
136
+ parts.push(
137
+ `stdout${stdout.truncated ? ` (truncated, full output: ${join(dir, "stdout.log")})` : ""}:\n${stdout.text.trim()}`,
138
+ );
139
+ }
140
+ if (stderr.text.trim() || stderr.truncated) {
141
+ parts.push(
142
+ `stderr${stderr.truncated ? ` (truncated, full output: ${join(dir, "stderr.log")})` : ""}:\n${stderr.text.trim()}`,
143
+ );
144
+ }
145
+ const header = watchHeader(result) ?? `[background_bash ${result.bash_id} finished]`;
146
+ const exitLine = result.unknown
147
+ ? "exit code: unknown (bash-rs restarted while this job was running)"
148
+ : `exit code: ${result.exit_code ?? "unknown"}`;
149
+ return `${header}\n${exitLine}\n${parts.join("\n") || "(no output)"}`;
150
+ }
151
+
152
+ /**
153
+ * A watch promises exactly one turn, and which outcome produced it is the
154
+ * whole point of the notice: "timed out" and "exited before matching" mean
155
+ * opposite things about whether the condition is still coming.
156
+ */
157
+ function watchHeader(result: JobResult): string | null {
158
+ if (result.matched_line) {
159
+ return `[background_bash_watch ${result.bash_id} matched]\nmatched line: ${result.matched_line.slice(0, 500)}`;
160
+ }
161
+ if (result.watch_outcome === "timeout") {
162
+ return `[background_bash_watch ${result.bash_id} timed out without a match]`;
163
+ }
164
+ if (result.watch_outcome === "exited") {
165
+ return `[background_bash_watch ${result.bash_id} exited before matching]`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function parseOwner(owner: string): { userId: string; topicId: string } | null {
171
+ const nul = owner.indexOf("\0");
172
+ if (nul < 0) return null;
173
+ const userId = owner.slice(0, nul);
174
+ const topicId = owner.slice(nul + 1);
175
+ if (!userId || !topicId) return null;
176
+ return { userId, topicId };
177
+ }
178
+
179
+ function injectedMarker(dir: string): string {
180
+ return join(dir, "result.json.injected");
181
+ }
182
+
183
+ /** One sweep: deliver any new `result.json`, then reap old delivered jobs. */
184
+ export async function flushBashrsCompletions(): Promise<void> {
185
+ let entries: string[];
186
+ try {
187
+ entries = readdirSync(BASHRS_SPILL_ROOT);
188
+ } catch {
189
+ return; // Nothing spawned yet.
190
+ }
191
+
192
+ const now = Date.now();
193
+ for (const bashId of entries) {
194
+ const dir = join(BASHRS_SPILL_ROOT, bashId);
195
+ const resultPath = join(dir, "result.json");
196
+ const marker = injectedMarker(dir);
197
+
198
+ // Already delivered: sweep once the retention window has passed.
199
+ try {
200
+ const markerStat = statSync(marker);
201
+ if (now - markerStat.mtimeMs > RETENTION_MS) {
202
+ rmSync(dir, { recursive: true, force: true });
203
+ }
204
+ continue;
205
+ } catch {
206
+ // No marker yet — fall through to try delivering it.
207
+ }
208
+
209
+ let raw: string;
210
+ try {
211
+ raw = readFileSync(resultPath, "utf8");
212
+ } catch {
213
+ continue; // Job still running, or not a bash-rs dir at all.
214
+ }
215
+
216
+ let result: unknown;
217
+ try {
218
+ result = JSON.parse(raw);
219
+ } catch (err) {
220
+ logger.warn({ err, dir }, "bashrs-completions: corrupt result.json, skipping");
221
+ continue;
222
+ }
223
+ if (!isJobResult(result)) {
224
+ logger.warn({ dir }, "bashrs-completions: result.json missing bash_id/owner, skipping");
225
+ continue;
226
+ }
227
+
228
+ const parsed = parseOwner(result.owner);
229
+ if (!parsed) {
230
+ // Not negotium's convention (e.g. clawgram's bare-topic owner, or a
231
+ // standalone curl user) — nothing for this watcher to do with it.
232
+ continue;
233
+ }
234
+
235
+ try {
236
+ // The stable bash_id is carried through so at-least-once delivery on
237
+ // either side — this watcher crashing before the rename, or the sink's
238
+ // own retry semantics — collapses to one turn rather than several.
239
+ completionSink({
240
+ userId: parsed.userId,
241
+ topicId: parsed.topicId,
242
+ bashId: result.bash_id,
243
+ message: buildMessage(dir, result),
244
+ });
245
+ renameSync(resultPath, marker);
246
+ logger.info(
247
+ { bashId: result.bash_id, userId: parsed.userId, topicId: parsed.topicId },
248
+ "bashrs-completions: delivered",
249
+ );
250
+ } catch (err) {
251
+ // Leave result.json in place — the next sweep retries. At-least-once,
252
+ // same as every other outbox in this codebase.
253
+ logger.warn({ err, dir }, "bashrs-completions: delivery failed, will retry");
254
+ }
255
+ }
256
+ }
257
+
258
+ let watcher: ReturnType<typeof watchDir> | null = null;
259
+ let fallbackTimer: ReturnType<typeof setInterval> | null = null;
260
+ let debouncedTrigger: (() => void) | null = null;
261
+ let leadershipTimer: ReturnType<typeof setInterval> | null = null;
262
+ let workerLease: RuntimeProcessLeaseHandle | null = null;
263
+ let workerStarted = false;
264
+
265
+ function stopLeaderResources(): void {
266
+ if (watcher) {
267
+ watcher.close();
268
+ watcher = null;
269
+ }
270
+ if (fallbackTimer) {
271
+ clearInterval(fallbackTimer);
272
+ fallbackTimer = null;
273
+ }
274
+ debouncedTrigger = null;
275
+ }
276
+
277
+ function tryBecomeLeader(): void {
278
+ if (!workerStarted || workerLease) return;
279
+ const acquired = acquireRuntimeProcessLease(PROCESS_ROLE, {
280
+ onLost: () => {
281
+ if (workerLease !== acquired) return;
282
+ workerLease = null;
283
+ stopLeaderResources();
284
+ logger.warn("bashrs-completions: worker leadership lost");
285
+ },
286
+ });
287
+ if (!acquired) return;
288
+
289
+ workerLease = acquired;
290
+ debouncedTrigger = debouncedFlush(flushBashrsCompletions, "bashrs-completions", 200);
291
+ watcher = watchDir(BASHRS_SPILL_ROOT, () => debouncedTrigger?.());
292
+ fallbackTimer = setInterval(() => debouncedTrigger?.(), FALLBACK_INTERVAL_MS);
293
+ fallbackTimer.unref?.();
294
+
295
+ logger.info(
296
+ { dir: BASHRS_SPILL_ROOT, role: PROCESS_ROLE },
297
+ "bashrs-completions: worker leadership acquired",
298
+ );
299
+ void flushBashrsCompletions();
300
+ }
301
+
302
+ /**
303
+ * Join the bashrs-completions worker election — same one-leader-per-role
304
+ * lease as `startSessionInboxWorker`, so exactly one runtime process watches
305
+ * `BASHRS_SPILL_ROOT` regardless of how many are up. Returns a cleanup
306
+ * function for graceful shutdown.
307
+ */
308
+ export function startBashrsCompletionsWorker(): () => void {
309
+ if (workerStarted) return () => {};
310
+ workerStarted = true;
311
+
312
+ tryBecomeLeader();
313
+ leadershipTimer = setInterval(tryBecomeLeader, PROCESS_LEASE_HEARTBEAT_MS);
314
+ leadershipTimer.unref?.();
315
+
316
+ return () => {
317
+ if (!workerStarted) return;
318
+ workerStarted = false;
319
+ if (leadershipTimer) {
320
+ clearInterval(leadershipTimer);
321
+ leadershipTimer = null;
322
+ }
323
+ stopLeaderResources();
324
+ workerLease?.stop();
325
+ workerLease = null;
326
+ logger.info("bashrs-completions: worker stopped");
327
+ };
328
+ }
@@ -1556,6 +1556,12 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1556
1556
  { topicId, err },
1557
1557
  "ai: ensureBgBash failed — proceeding without background bash tools",
1558
1558
  );
1559
+ // Say so in the turn. Losing the tool silently makes the model plan
1560
+ // around a capability it does not have, and the operator only finds
1561
+ // out by reading server logs.
1562
+ turnReminders.push(
1563
+ "<system-reminder>Background bash tools are UNAVAILABLE this turn. The `background_bash_*` tools could not be prepared, most often because the bash-rs binary is not installed on this host. Do not attempt to call them; run short commands in the foreground instead, and tell the user if the work genuinely needs a background job.</system-reminder>",
1564
+ );
1559
1565
  }
1560
1566
  }
1561
1567
  if (playwrightRequested && !browserProfileOwner) {
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.2.12";
1
+ export const NEGOTIUM_VERSION = "0.2.14";
@@ -496,6 +496,18 @@ function resolveBrowserRsBin(envValue) {
496
496
  }
497
497
  var SNIPPETS_API_URL = (envText("NEGOTIUM_SNIPPETS_API_URL") ?? envText("SNIPPETS_API_URL") ?? "").replace(/\/+$/, "");
498
498
  var BROWSER_RS_BIN = resolveBrowserRsBin(envText("NEGOTIUM_BROWSER_RS_BIN"));
499
+ var BASH_RS_VERSION = "v0.1.5";
500
+ function resolveBashRsBin(envValue) {
501
+ const override = envValue?.trim();
502
+ const candidate = override ? resolve(override) : resolve(BINARIES_DIR, "bash-rs", BASH_RS_VERSION, "bash-rs");
503
+ try {
504
+ accessSync(candidate, constants.X_OK);
505
+ return candidate;
506
+ } catch {
507
+ return;
508
+ }
509
+ }
510
+ var BASH_RS_BIN = resolveBashRsBin(envText("NEGOTIUM_BASH_RS_BIN"));
499
511
  function resolveBrowserMcpBin(envValue) {
500
512
  const override = envValue?.trim();
501
513
  if (override)
@@ -515,7 +527,6 @@ var TOKEN_STATS_SERVER = resolve(PROJECT_ROOT, "src/mcp/token-stats-server.ts");
515
527
  var COMPACTION_LOG_SERVER = resolve(PROJECT_ROOT, "src/mcp/compaction-log-server.ts");
516
528
  var SYSTEM_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/system-health-server.ts");
517
529
  var AGENT_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/agent-health-server.ts");
518
- var BACKGROUND_BASH_SERVER = resolve(PROJECT_ROOT, "src/mcp/background-bash-server.ts");
519
530
  var VAULT_SERVER = resolve(PROJECT_ROOT, "src/mcp/vault-server.ts");
520
531
  var BG_BASH_BASE_PORT = parsePortEnv(process.env.BG_BASH_BASE_PORT, 9700);
521
532
  var BG_BASH_MAX_PORT = parsePortEnv(process.env.BG_BASH_MAX_PORT, 9799);
@@ -572,6 +583,7 @@ var DM_CMD_DIR = resolve(RUN_DIR, "dm-commands");
572
583
  var DM_RESP_DIR = resolve(RUN_DIR, "dm-responses");
573
584
  var SESSION_INBOX_DIR = resolve(RUN_DIR, "session-inbox");
574
585
  var SESSION_ASKS_DIR = resolve(RUN_DIR, "session-asks");
586
+ var BASHRS_SPILL_ROOT = resolve(RUN_DIR, "bashrs");
575
587
  var PLAYWRIGHT_BASE_PORT = parsePortEnv(process.env.PLAYWRIGHT_BASE_PORT, 9100);
576
588
  var PLAYWRIGHT_MAX_PORT = parsePortEnv(process.env.PLAYWRIGHT_MAX_PORT, 9499);
577
589
  var PLAYWRIGHT_PORTS_DIR = resolve(RUN_DIR, "playwright-ports");
@@ -1133,4 +1145,4 @@ export {
1133
1145
  CLAUDE_EFFORT_VALUES
1134
1146
  };
1135
1147
 
1136
- //# debugId=530C52D9E19716B564756E2164756E21
1148
+ //# debugId=856D33930FED17E364756E2164756E21