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.
- package/dist/agent-helpers.js +34 -8
- package/dist/agent-helpers.js.map +6 -6
- package/dist/background-bash.js +32 -6
- package/dist/background-bash.js.map +4 -4
- package/dist/browser-runtime.js +14 -2
- package/dist/browser-runtime.js.map +3 -3
- package/dist/{chunk-8asy22vc.js → chunk-5g5a63vv.js} +32 -6
- package/dist/{chunk-8asy22vc.js.map → chunk-5g5a63vv.js.map} +4 -4
- package/dist/hosted-agent.js +33 -7
- package/dist/hosted-agent.js.map +5 -5
- package/dist/main.js +603 -353
- package/dist/main.js.map +12 -11
- package/dist/mcp-factories.js +34 -8
- package/dist/mcp-factories.js.map +6 -6
- package/dist/mcp-servers.js +3 -2
- package/dist/mcp-servers.js.map +3 -3
- package/dist/prompts.js +14 -2
- package/dist/prompts.js.map +3 -3
- package/dist/query-runtime.js +14 -2
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/registry.js.map +2 -2
- package/dist/rollout.js +1 -1
- package/dist/runtime/src/index.ts +9 -0
- package/dist/runtime/src/node-host.ts +9 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +45 -5
- package/dist/runtime/src/platform/config.ts +35 -2
- package/dist/runtime/src/runtime/bashrs-completions.ts +328 -0
- package/dist/runtime/src/runtime/turn-runner.ts +6 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-helpers.js +14 -2
- package/dist/runtime-helpers.js.map +3 -3
- package/dist/types/apps/negotium/src/mcp-servers.d.ts +16 -3
- package/dist/types/packages/core/src/platform/background-bash/manager.d.ts +6 -1
- package/dist/types/packages/core/src/platform/config.d.ts +21 -1
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js +14 -2
- package/dist/vault.js.map +3 -3
- package/install-bash-rs.mjs +77 -0
- package/package.json +3 -2
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1
|
+
export const NEGOTIUM_VERSION = "0.2.14";
|
package/dist/runtime-helpers.js
CHANGED
|
@@ -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=
|
|
1148
|
+
//# debugId=856D33930FED17E364756E2164756E21
|