botholomew 0.20.0 → 0.21.0

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/README.md CHANGED
@@ -76,13 +76,23 @@ the message queue, tool-call visualization, and the live workers panel:
76
76
 
77
77
  ## Install
78
78
 
79
- Requires [Bun](https://bun.sh) 1.1+.
79
+ **Prebuilt binary** (no Bun required — one self-contained file per platform):
80
+
81
+ ```bash
82
+ curl -fsSL https://raw.githubusercontent.com/evantahler/botholomew/main/install.sh | sh
83
+ ```
84
+
85
+ macOS (arm64) and Linux (x64); on Windows grab `botholomew-windows-x64.exe`
86
+ from the [releases](https://github.com/evantahler/botholomew/releases/latest). Then
87
+ `botholomew upgrade` updates it in place.
88
+
89
+ **With Bun** (requires [Bun](https://bun.sh) 1.1+):
80
90
 
81
91
  ```bash
82
92
  bun install -g botholomew
83
93
  ```
84
94
 
85
- The CLI installs as both `botholomew` and `bothy` — the same binary, two names.
95
+ Either way the CLI installs as both `botholomew` and `bothy` — the same binary, two names.
86
96
 
87
97
  Or run the dev build from a checkout:
88
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dev:demo": "bun run src/cli.ts chat -p 'learn everything you can about me from the connected MCP services and then save what you'\\''ve learned about me to context'",
22
22
  "test": "bun test",
23
23
  "lint": "tsc --noEmit && biome check .",
24
+ "build": "bun run scripts/build.ts",
24
25
  "capture": "bun run scripts/capture.ts",
25
26
  "docs:dev": "vitepress dev docs",
26
27
  "docs:build": "vitepress build docs",
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bun
2
+ // Virtual specifiers supplied + embedded (via the "file" loader) by
3
+ // scripts/build.ts at compile time; each resolves to its $bunfs path string.
4
+ // @ts-expect-error: virtual module resolved + embedded by scripts/build.ts at compile time
5
+ import dylibPath from "bothy:duckdb-dylib";
6
+ // @ts-expect-error: virtual module resolved + embedded by scripts/build.ts at compile time
7
+ import ortMjsPath from "bothy:ort-wasm-mjs";
8
+ // @ts-expect-error: virtual module resolved + embedded by scripts/build.ts at compile time
9
+ import ortWasmPath from "bothy:ort-wasm-wasm";
10
+ // Build-only entrypoint for the compiled binary (`bun run build`; see
11
+ // scripts/build.ts). Not used under `bun run` in dev.
12
+ //
13
+ // A `bun build --compile` binary has no node_modules on disk, and Bun resolves
14
+ // neither externalized native packages nor `import.meta.resolve(...)` against
15
+ // the real filesystem. So we EMBED the native assets that can't be bundled —
16
+ // DuckDB's shared library and onnxruntime-web's WASM runtime — and stage them
17
+ // in a temp dir BEFORE the code that loads them runs:
18
+ // • DuckDB: the embedded `duckdb.node` is extracted by Bun into os.tmpdir()
19
+ // and dlopens its library via an `@loader_path` rpath, so the library must
20
+ // sit in that same dir.
21
+ // • Embeddings: membot's embedder reads BOTHOLOMEW_ORT_WASM_{MJS,WASM} (the
22
+ // build rewrites its `import.meta.resolve(...)` calls to use these) instead
23
+ // of resolving onnxruntime-web from a (missing) node_modules.
24
+ // The dynamic `import("./cli.ts")` guarantees all of this is in place before the
25
+ // real CLI — and its transitive duckdb/transformers imports — are evaluated.
26
+ import {
27
+ existsSync,
28
+ readFileSync,
29
+ realpathSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+ import { pathToFileURL } from "node:url";
36
+ import { MCPX_CLI_SENTINEL, MEMBOT_CLI_SENTINEL } from "./runtime.ts";
37
+
38
+ const stageDir = realpathSync(tmpdir());
39
+
40
+ /** Copy an embedded asset into the temp dir (skip if an identical one exists). */
41
+ function stage(embedded: string, name: string): string {
42
+ const dest = join(stageDir, name);
43
+ if (!existsSync(dest) || statSync(dest).size !== statSync(embedded).size) {
44
+ writeFileSync(dest, readFileSync(embedded));
45
+ }
46
+ return dest;
47
+ }
48
+
49
+ // DuckDB shared library — must land where Bun extracts the addon (os.tmpdir())
50
+ // under exactly the name the addon's rpath expects. scripts/build.ts globs that
51
+ // name from the binding package and injects it here via --define.
52
+ declare const BOTHOLOMEW_DUCKDB_LIB: string;
53
+ stage(dylibPath, BOTHOLOMEW_DUCKDB_LIB);
54
+
55
+ // onnxruntime-web WASM runtime — staged side by side; membot's embedder reads
56
+ // these env vars (the build rewrites its import.meta.resolve calls).
57
+ const mjs = stage(ortMjsPath, "ort-wasm-simd-threaded.asyncify.mjs");
58
+ const wasm = stage(ortWasmPath, "ort-wasm-simd-threaded.asyncify.wasm");
59
+ process.env.BOTHOLOMEW_ORT_WASM_MJS = pathToFileURL(mjs).href;
60
+ process.env.BOTHOLOMEW_ORT_WASM_WASM = pathToFileURL(wasm).href;
61
+
62
+ // The membot/mcpx passthroughs re-exec this binary with a sentinel arg; hand
63
+ // off to the bundled upstream CLI in this dedicated process (commands/membot.ts,
64
+ // commands/mcpx.ts). Otherwise run the normal Botholomew CLI.
65
+ const argv = process.argv;
66
+ const membotIdx = argv.indexOf(MEMBOT_CLI_SENTINEL);
67
+ const mcpxIdx = argv.indexOf(MCPX_CLI_SENTINEL);
68
+ if (membotIdx !== -1) {
69
+ process.argv = [process.execPath, "membot", ...argv.slice(membotIdx + 1)];
70
+ await import("membot/cli");
71
+ } else if (mcpxIdx !== -1) {
72
+ process.argv = [process.execPath, "mcpx", ...argv.slice(mcpxIdx + 1)];
73
+ await import("@evantahler/mcpx/cli");
74
+ } else {
75
+ await import("./cli.ts");
76
+ }
package/src/cli.ts CHANGED
@@ -17,9 +17,27 @@ import { registerTaskCommand } from "./commands/task.ts";
17
17
  import { registerThreadCommand } from "./commands/thread.ts";
18
18
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
19
19
  import { registerWorkerCommand } from "./commands/worker.ts";
20
+ import { pkg } from "./pkg.ts";
21
+ import { IS_COMPILED_BINARY } from "./runtime.ts";
20
22
  import { maybeCheckForUpdate } from "./update/background.ts";
23
+ import { runWorkerFromArgv } from "./worker/run.ts";
24
+ import { WORKER_RUN_SENTINEL } from "./worker/sentinel.ts";
21
25
 
22
- const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
26
+ // In a compiled binary there is no source tree to `bun run`, so a backgrounded
27
+ // worker re-execs this binary with a sentinel arg (see worker/spawn.ts). Handle
28
+ // it before commander sees it. Also force the embedder to stay in-process —
29
+ // membot's subprocess pool would re-exec the binary with its own sentinel,
30
+ // which our CLI doesn't understand.
31
+ if (IS_COMPILED_BINARY) {
32
+ process.env.MEMBOT_EMBEDDING_WORKERS ??= "1";
33
+ // Find the sentinel by value rather than position — Bun's compiled-binary
34
+ // argv layout differs from `bun run`, so we slice everything after it.
35
+ const sentinelIdx = process.argv.indexOf(WORKER_RUN_SENTINEL);
36
+ if (sentinelIdx !== -1) {
37
+ await runWorkerFromArgv(process.argv.slice(sentinelIdx + 1));
38
+ process.exit(0);
39
+ }
40
+ }
23
41
 
24
42
  program
25
43
  .name("botholomew")
@@ -1,13 +1,10 @@
1
1
  import { cyan, dim, green, yellow } from "ansis";
2
2
  import type { Command } from "commander";
3
+ import { pkg } from "../pkg.ts";
3
4
  import { saveUpdateCache } from "../update/cache.ts";
4
5
  import type { UpdateCache } from "../update/checker.ts";
5
6
  import { checkForUpdate } from "../update/checker.ts";
6
7
 
7
- const pkg = await Bun.file(
8
- new URL("../../package.json", import.meta.url),
9
- ).json();
10
-
11
8
  export function registerCheckUpdateCommand(program: Command) {
12
9
  program
13
10
  .command("check-update")
@@ -8,22 +8,44 @@ import { createSpinner } from "nanospinner";
8
8
  import { loadConfig } from "../config/loader.ts";
9
9
  import { getMcpxDir } from "../constants.ts";
10
10
  import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
11
+ import { pkg as ourPkg } from "../pkg.ts";
11
12
  import { writeCapabilitiesFile } from "../prompts/capabilities.ts";
13
+ import { IS_COMPILED_BINARY, MCPX_CLI_SENTINEL } from "../runtime.ts";
12
14
  import { registerAllTools } from "../tools/registry.ts";
13
15
  import { logger } from "../utils/logger.ts";
14
16
 
15
- const require = createRequire(import.meta.url);
16
- const ourPkg = require("../../package.json");
17
- const mcpxPkg = require("@evantahler/mcpx/package.json");
18
-
19
- if (mcpxPkg.version !== ourPkg.dependencies["@evantahler/mcpx"]) {
20
- throw new Error(
21
- `@evantahler/mcpx version mismatch: installed ${mcpxPkg.version}, expected ${ourPkg.dependencies["@evantahler/mcpx"]}`,
22
- );
17
+ // Resolve the upstream mcpx CLI lazily — see commands/membot.ts for why this
18
+ // can't run at module load (no node_modules in a `bun build --compile` binary).
19
+ let mcpxCli: string | null | undefined;
20
+ function resolveMcpxCli(): string {
21
+ if (mcpxCli === undefined) {
22
+ mcpxCli = null;
23
+ try {
24
+ const cli = fileURLToPath(import.meta.resolve("@evantahler/mcpx/cli"));
25
+ if (existsSync(cli)) mcpxCli = cli;
26
+ } catch {
27
+ // unresolvable (e.g. standalone binary) — handled below
28
+ }
29
+ if (mcpxCli) {
30
+ // Strict version pin — only checkable when mcpx is actually reachable.
31
+ const require = createRequire(import.meta.url);
32
+ const mcpxPkg = require("@evantahler/mcpx/package.json");
33
+ if (mcpxPkg.version !== ourPkg.dependencies["@evantahler/mcpx"]) {
34
+ throw new Error(
35
+ `@evantahler/mcpx version mismatch: installed ${mcpxPkg.version}, expected ${ourPkg.dependencies["@evantahler/mcpx"]}`,
36
+ );
37
+ }
38
+ }
39
+ }
40
+ if (!mcpxCli) {
41
+ logger.error(
42
+ "The `botholomew mcpx` passthrough requires a reachable @evantahler/mcpx install, which the standalone binary doesn't bundle. Install botholomew via npm/Bun, or run `mcpx` directly.",
43
+ );
44
+ process.exit(1);
45
+ }
46
+ return mcpxCli;
23
47
  }
24
48
 
25
- const MCPX_CLI = fileURLToPath(import.meta.resolve("@evantahler/mcpx/cli"));
26
-
27
49
  export async function runMcpx(
28
50
  projectDir: string,
29
51
  args: (string | undefined)[],
@@ -35,7 +57,12 @@ export async function runMcpx(
35
57
  const config = await loadConfig(projectDir);
36
58
  const mcpxDir = resolveMcpxDir(projectDir, config);
37
59
  const filteredArgs = args.filter((a): a is string => a !== undefined);
38
- const proc = Bun.spawn(["bun", MCPX_CLI, ...filteredArgs, "-c", mcpxDir], {
60
+ // In the compiled binary, re-exec ourselves with the sentinel so the bundled
61
+ // mcpx CLI runs; under Bun, spawn the resolved on-disk mcpx CLI.
62
+ const cmd = IS_COMPILED_BINARY
63
+ ? [process.execPath, MCPX_CLI_SENTINEL, ...filteredArgs, "-c", mcpxDir]
64
+ : ["bun", resolveMcpxCli(), ...filteredArgs, "-c", mcpxDir];
65
+ const proc = Bun.spawn(cmd, {
39
66
  stdout: opts?.inherit ? "inherit" : "pipe",
40
67
  stderr: opts?.inherit ? "inherit" : "pipe",
41
68
  stdin: opts?.inherit ? "inherit" : undefined,
@@ -7,23 +7,52 @@ import type { Command } from "commander";
7
7
  import { defaultCliName, OPERATIONS } from "membot";
8
8
  import { loadConfig } from "../config/loader.ts";
9
9
  import { resolveMembotDir } from "../mem/client.ts";
10
+ import { pkg as ourPkg } from "../pkg.ts";
11
+ import { IS_COMPILED_BINARY, MEMBOT_CLI_SENTINEL } from "../runtime.ts";
10
12
  import { logger } from "../utils/logger.ts";
11
13
 
12
- const require = createRequire(import.meta.url);
13
- const ourPkg = require("../../package.json");
14
- const membotPkg = require("membot/package.json");
15
-
16
- // Soft warning rather than a hard error — membot's SDK API is stable within a
17
- // minor version, and dev workspaces sometimes pin a newer copy.
18
- const requested = (ourPkg.dependencies.membot as string).replace(/^[\^~]/, "");
19
- if (!membotPkg.version.startsWith(requested.split(".")[0])) {
20
- logger.warn(
21
- `membot version drift: installed ${membotPkg.version}, expected ${ourPkg.dependencies.membot}`,
22
- );
14
+ // Resolve the upstream membot CLI lazily. `import.meta.resolve` walks
15
+ // node_modules on disk, which a `bun build --compile` binary doesn't have, so
16
+ // resolving at module load would crash *every* command. On first use we resolve
17
+ // it, soft-check version drift, and fail this passthrough alone with a clear
18
+ // message when membot isn't reachable (e.g. inside the standalone binary).
19
+ let membotCli: string | null | undefined;
20
+ function resolveMembotCli(): string {
21
+ if (membotCli === undefined) {
22
+ membotCli = null;
23
+ try {
24
+ const cli = fileURLToPath(import.meta.resolve("membot/cli"));
25
+ if (existsSync(cli)) {
26
+ membotCli = cli;
27
+ try {
28
+ const require = createRequire(import.meta.url);
29
+ const membotPkg = require("membot/package.json");
30
+ const requested = (ourPkg.dependencies.membot as string).replace(
31
+ /^[\^~]/,
32
+ "",
33
+ );
34
+ if (!membotPkg.version.startsWith(requested.split(".")[0])) {
35
+ logger.warn(
36
+ `membot version drift: installed ${membotPkg.version}, expected ${ourPkg.dependencies.membot}`,
37
+ );
38
+ }
39
+ } catch {
40
+ // best-effort version check — ignore if package.json isn't readable
41
+ }
42
+ }
43
+ } catch {
44
+ // unresolvable (e.g. standalone binary) — handled below
45
+ }
46
+ }
47
+ if (!membotCli) {
48
+ logger.error(
49
+ "The `botholomew membot` passthrough requires a reachable membot install, which the standalone binary doesn't bundle. Install botholomew via npm/Bun, or run `membot` directly.",
50
+ );
51
+ process.exit(1);
52
+ }
53
+ return membotCli;
23
54
  }
24
55
 
25
- const MEMBOT_CLI = fileURLToPath(import.meta.resolve("membot/cli"));
26
-
27
56
  function getDir(program: Command): string {
28
57
  return program.opts().dir;
29
58
  }
@@ -45,7 +74,12 @@ async function runMembot(projectDir: string, args: string[]): Promise<number> {
45
74
  // `membot` directly.
46
75
  const config = await loadConfig(projectDir);
47
76
  const membotDir = resolveMembotDir(projectDir, config);
48
- const proc = Bun.spawn(["bun", MEMBOT_CLI, "--config", membotDir, ...args], {
77
+ // In the compiled binary, re-exec ourselves with the sentinel so the bundled
78
+ // membot CLI runs; under Bun, spawn the resolved on-disk membot CLI.
79
+ const cmd = IS_COMPILED_BINARY
80
+ ? [process.execPath, MEMBOT_CLI_SENTINEL, "--config", membotDir, ...args]
81
+ : ["bun", resolveMembotCli(), "--config", membotDir, ...args];
82
+ const proc = Bun.spawn(cmd, {
49
83
  stdout: "inherit",
50
84
  stderr: "inherit",
51
85
  stdin: "inherit",
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { dim, green, red, yellow } from "ansis";
4
4
  import { $ } from "bun";
5
5
  import type { Command } from "commander";
6
+ import { pkg } from "../pkg.ts";
6
7
  import {
7
8
  clearUpdateCache,
8
9
  loadUpdateCache,
@@ -16,10 +17,6 @@ import {
16
17
  needsCheck,
17
18
  } from "../update/checker.ts";
18
19
 
19
- const pkg = await Bun.file(
20
- new URL("../../package.json", import.meta.url),
21
- ).json();
22
-
23
20
  const GITHUB_REPO = (pkg.repository.url as string)
24
21
  .replace(/^https:\/\/github\.com\//, "")
25
22
  .replace(/\.git$/, "");
package/src/pkg.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Single source of truth for our own package.json metadata.
2
+ //
3
+ // Imported statically (not read at runtime via `Bun.file(new URL(...))`) so the
4
+ // values are inlined at `bun build --compile` time. A compiled standalone
5
+ // binary has no package.json on disk beside it, so a runtime read would throw
6
+ // at startup — even for `--version` / `--help`. The static import works in both
7
+ // `bun run` (read from disk) and the compiled binary (inlined).
8
+ import pkg from "../package.json" with { type: "json" };
9
+
10
+ export { pkg };
package/src/runtime.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Runtime-environment detection shared across the CLI.
2
+
3
+ /**
4
+ * True when running as a `bun build --compile` standalone binary rather than
5
+ * under the `bun` runtime (`bun run …` in dev, or the `bun install -g` shim).
6
+ *
7
+ * In a compiled binary `process.execPath` is the binary itself (e.g.
8
+ * `…/dist/bothy`); under Bun it is the `bun`/`bunx` executable. Several code
9
+ * paths must behave differently in a binary: there is no source tree or
10
+ * `node_modules` on disk, so we can't `bun run` a `.ts` worker entry and must
11
+ * instead re-exec the binary, and the embedder must stay in-process (no
12
+ * subprocess pool) since the pool would re-exec an unknown sentinel arg.
13
+ */
14
+ export const IS_COMPILED_BINARY = !/[\\/]bunx?(\.exe)?$/i.test(
15
+ process.execPath,
16
+ );
17
+
18
+ /**
19
+ * Sentinels the compiled binary re-execs itself with to run a bundled upstream
20
+ * CLI in a fresh process (the binary embeds membot's and mcpx's CLIs, so the
21
+ * `botholomew membot`/`botholomew mcpx` passthroughs don't need those CLIs
22
+ * resolvable on disk). See cli-standalone.ts, commands/membot.ts, commands/mcpx.ts.
23
+ */
24
+ export const MEMBOT_CLI_SENTINEL = "__botholomew_membot_cli__";
25
+ export const MCPX_CLI_SENTINEL = "__botholomew_mcpx_cli__";
@@ -1,12 +1,9 @@
1
1
  import { cyan, dim, yellow } from "ansis";
2
2
  import { DEFAULTS, ENV } from "../constants.ts";
3
+ import { pkg } from "../pkg.ts";
3
4
  import { loadUpdateCache, saveUpdateCache } from "./cache.ts";
4
5
  import { checkForUpdate, needsCheck, type UpdateCache } from "./checker.ts";
5
6
 
6
- const pkg = await Bun.file(
7
- new URL("../../package.json", import.meta.url),
8
- ).json();
9
-
10
7
  /** Format an update notice for stderr output. */
11
8
  function formatNotice(
12
9
  currentVersion: string,
@@ -1,8 +1,5 @@
1
1
  import { DEFAULTS } from "../constants.ts";
2
-
3
- const pkg = await Bun.file(
4
- new URL("../../package.json", import.meta.url),
5
- ).json();
2
+ import { pkg } from "../pkg.ts";
6
3
 
7
4
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
8
5
  const GITHUB_REPO = (pkg.repository.url as string)
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { SERVER_INSTRUCTIONS as MEMBOT_INSTRUCTIONS } from "membot";
4
4
  import type { BotholomewConfig } from "../config/schemas.ts";
5
5
  import { getPromptsDir } from "../constants.ts";
6
+ import { pkg } from "../pkg.ts";
6
7
  import type { Task } from "../tasks/schema.ts";
7
8
  import { parsePromptFile } from "../utils/frontmatter.ts";
8
9
 
@@ -17,10 +18,6 @@ export const MEMBOT_PROMPT_SECTION = `## Knowledge store (membot)
17
18
  ${MEMBOT_INSTRUCTIONS}
18
19
  `;
19
20
 
20
- const pkg = await Bun.file(
21
- new URL("../../package.json", import.meta.url),
22
- ).json();
23
-
24
21
  export const STYLE_RULES = `## Style
25
22
  - Open with the result, action, or next step. Skip preambles like "Great question", "You're absolutely right", "Let me…", "I'll go ahead and…".
26
23
  - Don't flatter the user or their ideas. If a request is wrong, ambiguous, or risky, say so plainly with the reason.
package/src/worker/run.ts CHANGED
@@ -1,34 +1,53 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- // Standalone entry point for a worker when spawned as a detached process.
4
- // Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]
3
+ // Worker entry when spawned as a detached process.
4
+ // - Dev / `bun install -g`: `bun run src/worker/run.ts <projectDir> [flags]`
5
+ // - Compiled binary: the parent re-execs the binary with WORKER_RUN_SENTINEL
6
+ // as argv[2] (see ../cli.ts and ./spawn.ts), then calls runWorkerFromArgv.
7
+ // Flags: [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]
5
8
 
6
9
  import { startWorker } from "./index.ts";
7
10
 
8
- const projectDir = process.argv[2];
9
- if (!projectDir) {
10
- console.error(
11
- "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
12
- );
13
- process.exit(1);
14
- }
11
+ const USAGE =
12
+ "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]";
13
+
14
+ /**
15
+ * Parse worker args (`<projectDir> [flags]`) and start the worker. Shared by
16
+ * the standalone `run.ts` invocation (dev) and the binary's sentinel branch.
17
+ */
18
+ export async function runWorkerFromArgv(args: string[]): Promise<void> {
19
+ const projectDir = args[0];
20
+ if (!projectDir) {
21
+ console.error(USAGE);
22
+ process.exit(1);
23
+ }
15
24
 
16
- const args = process.argv.slice(3);
17
- const persist = args.includes("--persist");
18
- const noEvalSchedules = args.includes("--no-eval-schedules");
19
- const taskIdArg = args.find((a) => a.startsWith("--task-id="));
20
- const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
21
- const workerIdArg = args.find((a) => a.startsWith("--worker-id="));
22
- const workerId = workerIdArg
23
- ? workerIdArg.slice("--worker-id=".length)
24
- : undefined;
25
- const logPathArg = args.find((a) => a.startsWith("--log-path="));
26
- const logPath = logPathArg ? logPathArg.slice("--log-path=".length) : undefined;
25
+ const flags = args.slice(1);
26
+ const persist = flags.includes("--persist");
27
+ const noEvalSchedules = flags.includes("--no-eval-schedules");
28
+ const taskIdArg = flags.find((a) => a.startsWith("--task-id="));
29
+ const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
30
+ const workerIdArg = flags.find((a) => a.startsWith("--worker-id="));
31
+ const workerId = workerIdArg
32
+ ? workerIdArg.slice("--worker-id=".length)
33
+ : undefined;
34
+ const logPathArg = flags.find((a) => a.startsWith("--log-path="));
35
+ const logPath = logPathArg
36
+ ? logPathArg.slice("--log-path=".length)
37
+ : undefined;
27
38
 
28
- await startWorker(projectDir, {
29
- mode: persist ? "persist" : "once",
30
- taskId,
31
- workerId,
32
- logPath,
33
- evalSchedules: noEvalSchedules ? false : undefined,
34
- });
39
+ await startWorker(projectDir, {
40
+ mode: persist ? "persist" : "once",
41
+ taskId,
42
+ workerId,
43
+ logPath,
44
+ evalSchedules: noEvalSchedules ? false : undefined,
45
+ });
46
+ }
47
+
48
+ // Only run when invoked directly (`bun run …/run.ts`), not when imported by
49
+ // cli.ts (the compiled binary's entrypoint), which would otherwise execute a
50
+ // worker on every CLI invocation.
51
+ if (import.meta.main) {
52
+ await runWorkerFromArgv(process.argv.slice(2));
53
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Sentinel arg the compiled binary re-execs itself with to run a background
3
+ * worker (see ../cli.ts and ./spawn.ts). Lives in its own leaf module — with no
4
+ * imports — so both the entrypoint and the spawner can use it without dragging
5
+ * the worker stack into a circular import.
6
+ */
7
+ export const WORKER_RUN_SENTINEL = "__botholomew_worker_run__";
@@ -1,16 +1,36 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { getConfigPath, getWorkerLogPath } from "../constants.ts";
4
+ import { IS_COMPILED_BINARY } from "../runtime.ts";
4
5
  import { logger } from "../utils/logger.ts";
5
6
  import { uuidv7 } from "../utils/uuid.ts";
6
7
  import { dateForId } from "../utils/v7-date.ts";
7
8
  import type { WorkerMode } from "./index.ts";
9
+ import { WORKER_RUN_SENTINEL } from "./sentinel.ts";
8
10
 
9
11
  export interface SpawnWorkerOptions {
10
12
  mode?: WorkerMode;
11
13
  taskId?: string;
12
14
  }
13
15
 
16
+ /**
17
+ * Env for a detached worker whose stdout/stderr is a log file: force no ANSI
18
+ * color. `ansis` (and other color libs) read these vars at import time, and
19
+ * `FORCE_COLOR` overrides `NO_COLOR`, so we must remove the forcing vars in
20
+ * addition to setting `NO_COLOR`. Without this, an interactive shell's inherited
21
+ * `FORCE_COLOR`/`COLORTERM` would make ansis emit escape codes into the log file
22
+ * even though the child's stdout is a file, not a TTY.
23
+ */
24
+ export function colorlessEnv(
25
+ base: Record<string, string | undefined> = process.env,
26
+ ): Record<string, string | undefined> {
27
+ const env: Record<string, string | undefined> = { ...base };
28
+ env.NO_COLOR = "1";
29
+ delete env.FORCE_COLOR;
30
+ delete env.CLICOLOR_FORCE;
31
+ return env;
32
+ }
33
+
14
34
  /**
15
35
  * Spawn a worker as a detached background process. Unlike the old daemon
16
36
  * model, multiple workers per project are allowed and expected — this just
@@ -38,21 +58,19 @@ export async function spawnWorker(
38
58
  await mkdir(dirname(logPath), { recursive: true });
39
59
  const logFile = Bun.file(logPath);
40
60
 
41
- const workerScript = new URL("./run.ts", import.meta.url).pathname;
42
- const args = [
43
- "bun",
44
- "run",
45
- workerScript,
46
- projectDir,
47
- `--worker-id=${workerId}`,
48
- `--log-path=${logPath}`,
49
- ];
61
+ // In a compiled binary there is no `run.ts` on disk to `bun run`, so re-exec
62
+ // this binary with the sentinel arg that cli.ts intercepts. Under Bun (dev /
63
+ // global install) spawn the worker entry directly.
64
+ const args = IS_COMPILED_BINARY
65
+ ? [process.execPath, WORKER_RUN_SENTINEL, projectDir]
66
+ : ["bun", "run", new URL("./run.ts", import.meta.url).pathname, projectDir];
67
+ args.push(`--worker-id=${workerId}`, `--log-path=${logPath}`);
50
68
  if (options.mode === "persist") args.push("--persist");
51
69
  if (options.taskId) args.push(`--task-id=${options.taskId}`);
52
70
 
53
71
  const proc = Bun.spawn(args, {
54
72
  stdio: ["ignore", logFile, logFile],
55
- env: { ...process.env },
73
+ env: colorlessEnv(),
56
74
  });
57
75
  proc.unref();
58
76