negotium 0.2.13 → 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 +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/turn-runner.ts +6 -0
  30. package/dist/runtime/src/version.ts +1 -1
  31. package/dist/runtime-helpers.js +2 -3
  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 +8 -6
  36. package/dist/types/packages/core/src/version.d.ts +1 -1
  37. package/dist/vault.js +2 -3
  38. package/dist/vault.js.map +3 -3
  39. package/install-bash-rs.mjs +5 -5
  40. package/package.json +1 -1
  41. package/dist/runtime/src/mcp/background-bash-server.ts +0 -756
@@ -1,8 +1,8 @@
1
1
  import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import {
4
- BACKGROUND_BASH_SERVER,
5
4
  BASH_RS_BIN,
5
+ BASH_RS_VERSION,
6
6
  BASHRS_SPILL_ROOT,
7
7
  BG_BASH_BASE_PORT,
8
8
  BG_BASH_MAX_PORT,
@@ -31,7 +31,12 @@ export interface BackgroundBashManager {
31
31
  }
32
32
 
33
33
  export interface BackgroundBashManagerOptions {
34
- 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;
35
40
  basePort?: number;
36
41
  maxPort?: number;
37
42
  capability?: string;
@@ -71,7 +76,7 @@ export function createBackgroundBashManager(
71
76
  const knownContexts = new Map<string, { userId: string; topic: string }>();
72
77
  const runtimeCapability = options.capability ?? randomBytes(32).toString("hex");
73
78
  const runtimeServerId = options.serverId ?? randomBytes(16).toString("hex");
74
- const serverFile = options.serverFile ?? BACKGROUND_BASH_SERVER;
79
+ const bashRsBin = options.bashRsBin ?? BASH_RS_BIN;
75
80
  const basePort = options.basePort ?? BG_BASH_BASE_PORT;
76
81
  const maxPort = options.maxPort ?? BG_BASH_MAX_PORT;
77
82
  const fetchImpl = options.fetch ?? globalThis.fetch;
@@ -110,20 +115,17 @@ export function createBackgroundBashManager(
110
115
  signal: AbortSignal.timeout(2000),
111
116
  });
112
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.
113
120
  const body = await response.text();
114
- // bash-rs (Rust binary) answers with JSON + `instance_id`; the legacy
115
- // TS server answers with the bare id as `text/plain`. Both exist to
116
- // confirm this port is answering *our* spawned process, not a stale
117
- // one left over from a previous manager instance.
118
121
  try {
119
122
  const parsed = JSON.parse(body);
120
- if (parsed && typeof parsed === "object" && "instance_id" in parsed) {
121
- return parsed.instance_id === runtimeServerId;
122
- }
123
+ return (
124
+ Boolean(parsed) && typeof parsed === "object" && parsed.instance_id === runtimeServerId
125
+ );
123
126
  } catch {
124
- // Not JSON — fall through to the plain-text comparison below.
127
+ return false;
125
128
  }
126
- return body === runtimeServerId;
127
129
  } catch {
128
130
  return false;
129
131
  }
@@ -146,17 +148,23 @@ export function createBackgroundBashManager(
146
148
  reservedPort?: number,
147
149
  excludedPorts: ReadonlySet<number> = new Set(),
148
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
+ }
149
166
  const port = reservedPort ?? (await allocatePort(excludedPorts));
150
- // Prefer the Rust binary when the installer (install-bash-rs.mjs) put
151
- // one in place; otherwise fall back to the TS server unconditionally —
152
- // e.g. no prebuilt binary for this platform/arch yet. Both speak the
153
- // same wire protocol (X-Background-Bash-* headers, HMAC capability
154
- // derived from the same `runtimeCapability` root secret), so either one
155
- // is a transparent swap from the caller's point of view.
156
- const [command, args] = BASH_RS_BIN
157
- ? [BASH_RS_BIN, [String(port)]]
158
- : ["bun", ["run", serverFile, `--port=${port}`]];
159
- const process = spawnImpl(command, args, {
167
+ const process = spawnImpl(bashRsBin, [String(port)], {
160
168
  stdio: "ignore",
161
169
  detached: false,
162
170
  env: {
@@ -168,13 +168,16 @@ export const SNIPPETS_API_URL = (
168
168
  export const BROWSER_RS_BIN = resolveBrowserRsBin(envText("NEGOTIUM_BROWSER_RS_BIN"));
169
169
 
170
170
  /** bash-rs release tested with this Negotium version — see apps/negotium/install-bash-rs.mjs. */
171
- export const BASH_RS_VERSION = "v0.1.2";
171
+ export const BASH_RS_VERSION = "v0.1.5";
172
172
 
173
173
  /**
174
174
  * Resolve the bash-rs binary the same way `resolveBrowserRsBin` resolves
175
175
  * Browser.rs: a versioned private location, no PATH lookup, `undefined`
176
- * (rather than throwing) when it's missing callers fall back to the TS
177
- * `background-bash-server.ts` in that case (see `background-bash/manager.ts`).
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.
178
181
  */
179
182
  export function resolveBashRsBin(envValue?: string): string | undefined {
180
183
  const override = envValue?.trim();
@@ -281,8 +284,6 @@ export const SYSTEM_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/system-health
281
284
 
282
285
  export const AGENT_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/agent-health-server.ts");
283
286
 
284
- export const BACKGROUND_BASH_SERVER = resolve(PROJECT_ROOT, "src/mcp/background-bash-server.ts");
285
-
286
287
  export const VAULT_SERVER = resolve(PROJECT_ROOT, "src/mcp/vault-server.ts");
287
288
 
288
289
  export const BG_BASH_BASE_PORT = parsePortEnv(process.env.BG_BASH_BASE_PORT, 9700);
@@ -385,8 +386,8 @@ 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
386
387
  * here (`{bash_id}/meta.json`, `result.json`, `stdout.log`, `stderr.log`) —
387
388
  * see bash-rs-mcp's `journal.rs`. `runtime/bashrs-completions.ts` watches
388
- * this directory and turns `result.json` into a session-inbox `tell`, the
389
- * same way `background-bash-server.ts` used to write one directly.
389
+ * this directory and turns `result.json` into a session-inbox `tell`. The
390
+ * retired TypeScript server used to write one directly instead.
390
391
  */
391
392
  export const BASHRS_SPILL_ROOT = resolve(RUN_DIR, "bashrs");
392
393
  export const PLAYWRIGHT_BASE_PORT = parsePortEnv(process.env.PLAYWRIGHT_BASE_PORT, 9100);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * bash-rs completion watcher — turns `result.json` files written by the Rust
3
- * `bash-rs` background-bash daemon into session-inbox `tell`s, the same way
4
- * `background-bash-server.ts` used to call `injectMessage()` directly.
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
5
  *
6
6
  * Why a watcher instead of bash-rs calling back into negotium: bash-rs is a
7
7
  * standalone project (github.com/maestrojeong/bash-rs-mcp, sibling of
@@ -40,6 +40,46 @@ import {
40
40
  type RuntimeProcessLeaseHandle,
41
41
  } from "#storage/runtime-process-leases";
42
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
+
43
83
  const PROCESS_ROLE = "worker:bashrs-completions";
44
84
  /** How long a delivered job's spill dir (logs + result.json.injected) is kept
45
85
  * around before being swept — mirrors the TS server's own
@@ -56,6 +96,12 @@ interface JobResult {
56
96
  exit_code: number | null;
57
97
  finished_at_ms: number;
58
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";
59
105
  unknown: boolean;
60
106
  }
61
107
 
@@ -96,15 +142,31 @@ function buildMessage(dir: string, result: JobResult): string {
96
142
  `stderr${stderr.truncated ? ` (truncated, full output: ${join(dir, "stderr.log")})` : ""}:\n${stderr.text.trim()}`,
97
143
  );
98
144
  }
99
- const header = result.matched_line
100
- ? `[background_bash_watch ${result.bash_id} matched]\nmatched line: ${result.matched_line.slice(0, 500)}`
101
- : `[background_bash ${result.bash_id} finished]`;
145
+ const header = watchHeader(result) ?? `[background_bash ${result.bash_id} finished]`;
102
146
  const exitLine = result.unknown
103
147
  ? "exit code: unknown (bash-rs restarted while this job was running)"
104
148
  : `exit code: ${result.exit_code ?? "unknown"}`;
105
149
  return `${header}\n${exitLine}\n${parts.join("\n") || "(no output)"}`;
106
150
  }
107
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
+
108
170
  function parseOwner(owner: string): { userId: string; topicId: string } | null {
109
171
  const nul = owner.indexOf("\0");
110
172
  if (nul < 0) return null;
@@ -171,16 +233,14 @@ export async function flushBashrsCompletions(): Promise<void> {
171
233
  }
172
234
 
173
235
  try {
174
- appendJsonlEntry(sessionInboxPath(parsed.userId, parsed.topicId), {
175
- type: "tell",
176
- from: "__bg_bash__",
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,
177
243
  message: buildMessage(dir, result),
178
- depth: 0,
179
- // Stable per-job id: at-least-once delivery on both sides (this
180
- // watcher's own crash-before-rename, and the session-inbox worker's
181
- // own at-least-once semantics) collapses to one turn, not several.
182
- requestId: result.bash_id,
183
- timestamp: new Date().toISOString(),
184
244
  });
185
245
  renameSync(resultPath, marker);
186
246
  logger.info(
@@ -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.13";
1
+ export const NEGOTIUM_VERSION = "0.2.14";
@@ -496,7 +496,7 @@ 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.2";
499
+ var BASH_RS_VERSION = "v0.1.5";
500
500
  function resolveBashRsBin(envValue) {
501
501
  const override = envValue?.trim();
502
502
  const candidate = override ? resolve(override) : resolve(BINARIES_DIR, "bash-rs", BASH_RS_VERSION, "bash-rs");
@@ -527,7 +527,6 @@ var TOKEN_STATS_SERVER = resolve(PROJECT_ROOT, "src/mcp/token-stats-server.ts");
527
527
  var COMPACTION_LOG_SERVER = resolve(PROJECT_ROOT, "src/mcp/compaction-log-server.ts");
528
528
  var SYSTEM_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/system-health-server.ts");
529
529
  var AGENT_HEALTH_SERVER = resolve(PROJECT_ROOT, "src/mcp/agent-health-server.ts");
530
- var BACKGROUND_BASH_SERVER = resolve(PROJECT_ROOT, "src/mcp/background-bash-server.ts");
531
530
  var VAULT_SERVER = resolve(PROJECT_ROOT, "src/mcp/vault-server.ts");
532
531
  var BG_BASH_BASE_PORT = parsePortEnv(process.env.BG_BASH_BASE_PORT, 9700);
533
532
  var BG_BASH_MAX_PORT = parsePortEnv(process.env.BG_BASH_MAX_PORT, 9799);
@@ -1146,4 +1145,4 @@ export {
1146
1145
  CLAUDE_EFFORT_VALUES
1147
1146
  };
1148
1147
 
1149
- //# debugId=3113220EF57A6BCB64756E2164756E21
1148
+ //# debugId=856D33930FED17E364756E2164756E21