botholomew 0.7.13 → 0.8.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.
Files changed (54) hide show
  1. package/README.md +37 -32
  2. package/package.json +1 -1
  3. package/src/chat/agent.ts +13 -11
  4. package/src/cli.ts +2 -2
  5. package/src/commands/chat.ts +29 -44
  6. package/src/commands/nuke.ts +11 -8
  7. package/src/commands/schedule.ts +1 -1
  8. package/src/commands/thread.ts +2 -2
  9. package/src/commands/with-db.ts +1 -1
  10. package/src/commands/worker.ts +231 -0
  11. package/src/config/schemas.ts +12 -0
  12. package/src/constants.ts +1 -27
  13. package/src/db/schedules.ts +66 -0
  14. package/src/db/schema.ts +5 -4
  15. package/src/db/sql/12-workers.sql +66 -0
  16. package/src/db/tasks.ts +25 -1
  17. package/src/db/threads.ts +1 -1
  18. package/src/db/workers.ts +207 -0
  19. package/src/init/index.ts +3 -1
  20. package/src/tools/context/read-large-result.ts +1 -1
  21. package/src/tools/mcp/exec.ts +1 -1
  22. package/src/tools/mcp/search.ts +1 -1
  23. package/src/tools/registry.ts +5 -0
  24. package/src/tools/thread/list.ts +2 -2
  25. package/src/tools/worker/spawn.ts +50 -0
  26. package/src/tui/App.tsx +15 -7
  27. package/src/tui/components/HelpPanel.tsx +5 -5
  28. package/src/tui/components/StatusBar.tsx +22 -18
  29. package/src/tui/components/TabBar.tsx +3 -2
  30. package/src/tui/components/ThreadPanel.tsx +7 -7
  31. package/src/tui/components/WorkerPanel.tsx +207 -0
  32. package/src/utils/title.ts +1 -1
  33. package/src/worker/heartbeat.ts +78 -0
  34. package/src/worker/index.ts +200 -0
  35. package/src/{daemon → worker}/llm.ts +5 -5
  36. package/src/{daemon → worker}/prompt.ts +2 -2
  37. package/src/worker/run.ts +26 -0
  38. package/src/{daemon → worker}/schedules.ts +30 -2
  39. package/src/worker/spawn.ts +48 -0
  40. package/src/{daemon → worker}/tick.ts +93 -35
  41. package/src/commands/daemon.ts +0 -152
  42. package/src/daemon/ensure-running.ts +0 -16
  43. package/src/daemon/healthcheck.ts +0 -47
  44. package/src/daemon/index.ts +0 -106
  45. package/src/daemon/run.ts +0 -14
  46. package/src/daemon/spawn.ts +0 -38
  47. package/src/daemon/watchdog.ts +0 -306
  48. package/src/utils/pid.ts +0 -55
  49. package/src/utils/project-registry.ts +0 -48
  50. /package/src/{daemon → worker}/context.ts +0 -0
  51. /package/src/{daemon → worker}/fake-llm.ts +0 -0
  52. /package/src/{daemon → worker}/fake-mcp.ts +0 -0
  53. /package/src/{daemon → worker}/large-results.ts +0 -0
  54. /package/src/{daemon → worker}/llm-client.ts +0 -0
package/README.md CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  ![Botholomew chat TUI](docs/assets/chat-happy-path.gif)
10
10
 
11
- **A local-first AI agent for knowledge work.** Botholomew is a long-running
12
- autonomous agent that works its way through a task queue — reading email,
13
- summarizing documents, researching topics, organizing notes, and maintaining
14
- context over time — while you sleep, work, or chat with it.
11
+ **A local AI agent for knowledge work.** Botholomew is an autonomous agent
12
+ that works its way through a task queue — reading email, summarizing
13
+ documents, researching topics, organizing notes, and maintaining context
14
+ over time — while you sleep, work, or chat with it.
15
15
 
16
16
  Unlike coding agents, Botholomew has **no shell, no filesystem, and no network
17
17
  tools** by default. Everything it touches lives inside a single DuckDB database
@@ -22,12 +22,12 @@ is granted deliberately, per project, through MCP servers.
22
22
 
23
23
  ## Why Botholomew?
24
24
 
25
- - **Autonomous.** A background daemon ticks on a schedule, claims tasks,
26
- works them with Claude, and logs every interaction. You can close the
27
- terminal and come back later.
25
+ - **Autonomous.** Background **workers** claim tasks, work them with Claude,
26
+ and log every interaction. You can spawn one-shot workers on demand, a
27
+ long-running `--persist` worker, or point cron at `botholomew worker run`.
28
28
  - **Portable.** Each project is a `.botholomew/` directory — markdown +
29
29
  DuckDB. Copy it, share it, check it in (or `.gitignore` it).
30
- - **Local-first.** All data stays on your machine. Embeddings are indexed in
30
+ - **Local.** All data stays on your machine. Embeddings are indexed in
31
31
  DuckDB's native vector store with HNSW. Model calls go direct to Anthropic
32
32
  and OpenAI.
33
33
  - **Extensible.** External tools come from MCP servers via
@@ -40,8 +40,9 @@ is granted deliberately, per project, through MCP servers.
40
40
  filesystem access of its own. Everything it can touch lives in
41
41
  `.botholomew/` — and every external capability is something you
42
42
  explicitly add.
43
- - **Self-healing.** An OS-level watchdog (launchd on macOS, systemd on Linux)
44
- restarts the daemon if it dies, rotates logs, and runs on boot.
43
+ - **Concurrent.** Many workers can run at once. Each registers itself in
44
+ the DB and heartbeats; crashed workers get reaped and their tasks go
45
+ back into the queue automatically.
45
46
  - **Self-modifying.** The agent maintains its own `beliefs.md` and
46
47
  `goals.md` — it learns, updates its priors, and revises its goals as it
47
48
  works.
@@ -80,13 +81,17 @@ export OPENAI_API_KEY=sk-... # used for embeddings
80
81
  # 3. Queue some work
81
82
  botholomew task add "Summarize every markdown file in ~/notes"
82
83
 
83
- # 4. Start the daemon (foreground watch it work)
84
- botholomew daemon start --foreground
84
+ # 4. Run a worker to process the queue
85
+ botholomew worker run # one-shot: claim and run one task
86
+ botholomew worker run --persist # long-running: loop until you stop it
85
87
 
86
88
  # 5. Or chat with the agent interactively
87
89
  botholomew chat
88
90
  ```
89
91
 
92
+ See [docs/automation.md](docs/automation.md) for cron-based setups if you
93
+ want Botholomew to advance on its own.
94
+
90
95
  ---
91
96
 
92
97
  ## What a project looks like
@@ -103,8 +108,7 @@ my-project/
103
108
  skills/ # user-defined slash commands
104
109
  summarize.md
105
110
  standup.md
106
- daemon.pid # PID file for the running daemon
107
- daemon.log # rotating daemon logs
111
+ worker.log # stdout/stderr from spawned workers
108
112
  ```
109
113
 
110
114
  Everything the agent can touch is here. No surprises.
@@ -118,9 +122,8 @@ Everything the agent can touch is here. No surprises.
118
122
  | Command | Purpose |
119
123
  |---|---|
120
124
  | `botholomew init` | Create `.botholomew/` with templates and a fresh database |
121
- | `botholomew daemon start\|stop\|status` | Run, stop, or inspect the daemon |
122
- | `botholomew daemon install\|uninstall` | Register/remove the OS watchdog |
123
- | `botholomew daemon list` | List all Botholomew projects on this machine |
125
+ | `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task |
126
+ | `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
124
127
  | `botholomew chat` | Interactive Ink/React TUI |
125
128
  | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
126
129
  | `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
@@ -140,16 +143,16 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
140
143
 
141
144
  ```
142
145
  ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
143
- │ Chat │ │ Daemon │ │ Watchdog
144
- │ (Ink TUI) │ │ (tick loop) │ │ launchd/
145
- │ │ │ │ │ systemd
146
+ │ Chat │ │ Worker(s) │ │ cron /
147
+ │ (Ink TUI) │ │ (tick loop) │ │ tmux
148
+ │ │ │ │ │ (optional)
146
149
  └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
147
150
  │ │ │
148
- │ enqueue tasks │ claims tasks every 60s:
149
- │ browse history │ runs LLM tool loops check PID
150
- invoke skills updates status restart if
151
- logs to threadsdead
152
- │ │
151
+ │ enqueue tasks │ register + heartbeat fire
152
+ │ browse history │ claim tasks `worker run`
153
+ spawn_worker tool run LLM tool loops on a
154
+ invoke skills reap dead peersschedule
155
+ │ │ log to threads
153
156
  └────────────┬───────────┴────────────┬───────────┘
154
157
  │ │
155
158
  ┌─────▼────────────────────────▼─────┐
@@ -157,7 +160,8 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
157
160
  │ ┌───────────┐ ┌──────────────┐ │
158
161
  │ │ tasks │ │ context_items│ │
159
162
  │ │ schedules │ │ embeddings │ │
160
- │ │ threads │ │ (HNSW) │ │
163
+ │ │ workers │ │ (HNSW) │ │
164
+ │ │ threads │ │ │ │
161
165
  │ └───────────┘ └──────────────┘ │
162
166
  └─────┬───────────────────────────────┘
163
167
 
@@ -173,11 +177,14 @@ See [docs/architecture.md](docs/architecture.md) for a deeper tour.
173
177
 
174
178
  Topics worth understanding in detail:
175
179
 
176
- - **[Architecture](docs/architecture.md)** — daemon, chat, watchdog, and how
177
- they share a database.
180
+ - **[Architecture](docs/architecture.md)** — workers, chat, and how
181
+ they share a database. Registration, heartbeat, and reaping.
182
+ - **[Automation](docs/automation.md)** — cron recipes and optional
183
+ launchd/systemd samples for running workers on a schedule without a
184
+ shipped watchdog.
178
185
  - **[The TUI](docs/tui.md)** — the `botholomew chat` Ink/React terminal UI:
179
- seven tabs, slash-command autocomplete, message queue, and tool-call
180
- visualization.
186
+ eight tabs, slash-command autocomplete, message queue, tool-call
187
+ visualization, and a live workers panel.
181
188
  - **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
182
189
  "files" are actually DuckDB rows, and how `context_read`/`context_write` work.
183
190
  - **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
@@ -193,8 +200,6 @@ Topics worth understanding in detail:
193
200
  with positional arguments and tab completion.
194
201
  - **[MCPX integration](docs/mcpx.md)** — configuring external servers and
195
202
  how MCP tools are merged into the agent's toolset.
196
- - **[The watchdog](docs/watchdog.md)** — launchd plists, systemd units, and
197
- multi-project service naming.
198
203
  - **[Configuration](docs/configuration.md)** — every key in `config.json`
199
204
  and its default.
200
205
  - **[Doc captures](docs/captures.md)** — how the screenshots and GIFs in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.7.13",
3
+ "version": "0.8.0",
4
4
  "description": "Local, autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -6,14 +6,6 @@ import type {
6
6
  import type { McpxClient } from "@evantahler/mcpx";
7
7
  import type { BotholomewConfig } from "../config/schemas.ts";
8
8
  import { embedSingle } from "../context/embedder.ts";
9
- import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
10
- import { maybeStoreResult } from "../daemon/large-results.ts";
11
- import { createLlmClient } from "../daemon/llm-client.ts";
12
- import {
13
- buildMetaHeader,
14
- extractKeywords,
15
- loadPersistentContext,
16
- } from "../daemon/prompt.ts";
17
9
  import { withDb } from "../db/connection.ts";
18
10
  import { hybridSearch } from "../db/embeddings.ts";
19
11
  import { logInteraction } from "../db/threads.ts";
@@ -25,10 +17,18 @@ import {
25
17
  toAnthropicTool,
26
18
  } from "../tools/tool.ts";
27
19
  import { logger } from "../utils/logger.ts";
20
+ import { fitToContextWindow, getMaxInputTokens } from "../worker/context.ts";
21
+ import { maybeStoreResult } from "../worker/large-results.ts";
22
+ import { createLlmClient } from "../worker/llm-client.ts";
23
+ import {
24
+ buildMetaHeader,
25
+ extractKeywords,
26
+ loadPersistentContext,
27
+ } from "../worker/prompt.ts";
28
28
 
29
29
  registerAllTools();
30
30
 
31
- /** Tools available in chat mode — no daemon terminal tools, no destructive file tools */
31
+ /** Tools available in chat mode — no worker terminal tools, no destructive file tools */
32
32
  const CHAT_TOOL_NAMES = new Set([
33
33
  "create_task",
34
34
  "list_tasks",
@@ -36,6 +36,7 @@ const CHAT_TOOL_NAMES = new Set([
36
36
  "context_search",
37
37
  "context_info",
38
38
  "context_refresh",
39
+ "context_tree",
39
40
  "search_grep",
40
41
  "search_semantic",
41
42
  "list_threads",
@@ -49,6 +50,7 @@ const CHAT_TOOL_NAMES = new Set([
49
50
  "mcp_info",
50
51
  "mcp_exec",
51
52
  "read_large_result",
53
+ "spawn_worker",
52
54
  ]);
53
55
 
54
56
  export function getChatTools() {
@@ -102,10 +104,10 @@ export async function buildChatSystemPrompt(
102
104
 
103
105
  parts.push("## Instructions");
104
106
  parts.push(
105
- "You are Botholomew, an AI agent personified by a wise owl. This is your interactive chat interface. Help the user manage tasks, review results from daemon activity, search context, and answer questions.",
107
+ "You are Botholomew, an AI agent personified by a wise owl. This is your interactive chat interface. Help the user manage tasks, review results from background worker activity, search context, and answer questions.",
106
108
  );
107
109
  parts.push(
108
- "You do NOT execute long-running work directly — enqueue tasks for the daemon instead using create_task.",
110
+ "You do NOT execute long-running work directly — enqueue tasks for a background worker instead using create_task, and spawn a worker via spawn_worker when the user wants the task run now.",
109
111
  );
110
112
  parts.push(
111
113
  "Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items can be looked up by virtual path or by UUID via `context_info` and refreshed via `context_refresh`.",
package/src/cli.ts CHANGED
@@ -5,7 +5,6 @@ import { program } from "commander";
5
5
  import { registerChatCommand } from "./commands/chat.ts";
6
6
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
7
7
  import { registerContextCommand } from "./commands/context.ts";
8
- import { registerDaemonCommand } from "./commands/daemon.ts";
9
8
  import { registerInitCommand } from "./commands/init.ts";
10
9
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
10
  import { registerNukeCommand } from "./commands/nuke.ts";
@@ -15,6 +14,7 @@ import { registerSkillCommand } from "./commands/skill.ts";
15
14
  import { registerTaskCommand } from "./commands/task.ts";
16
15
  import { registerThreadCommand } from "./commands/thread.ts";
17
16
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
17
+ import { registerWorkerCommand } from "./commands/worker.ts";
18
18
  import { maybeCheckForUpdate } from "./update/background.ts";
19
19
 
20
20
  const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
@@ -33,7 +33,7 @@ program
33
33
  });
34
34
 
35
35
  registerInitCommand(program);
36
- registerDaemonCommand(program);
36
+ registerWorkerCommand(program);
37
37
  registerTaskCommand(program);
38
38
  registerThreadCommand(program);
39
39
  registerScheduleCommand(program);
@@ -16,49 +16,34 @@ export function registerChatCommand(program: Command) {
16
16
  )
17
17
  .option("--thread-id <id>", "Resume an existing chat thread")
18
18
  .option("-p, --prompt <text>", "Start chat with an initial prompt")
19
- .option("--no-daemon", "don't auto-start the daemon")
20
- .action(
21
- async (opts: {
22
- threadId?: string;
23
- prompt?: string;
24
- daemon?: boolean;
25
- }) => {
26
- const { render } = await import("ink");
27
- const React = await import("react");
28
- const { App } = await import("../tui/App.tsx");
29
- const dir = program.opts().dir;
19
+ .action(async (opts: { threadId?: string; prompt?: string }) => {
20
+ const { render } = await import("ink");
21
+ const React = await import("react");
22
+ const { App } = await import("../tui/App.tsx");
23
+ const dir = program.opts().dir;
30
24
 
31
- // Auto-spawn daemon if not running (attached mode)
32
- if (opts.daemon !== false) {
33
- const { ensureDaemonRunning } = await import(
34
- "../daemon/ensure-running.ts"
35
- );
36
- await ensureDaemonRunning(dir);
37
- }
38
-
39
- // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
40
- // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
41
- // capture. Use "disabled" mode in capture to keep text input working;
42
- // captures that need Tab/Escape should use the `-p` prompt flag or
43
- // a /slash command typed as text instead.
44
- const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
45
- const instance = render(
46
- React.createElement(App, {
47
- projectDir: dir,
48
- threadId: opts.threadId,
49
- initialPrompt: opts.prompt,
50
- }),
51
- {
52
- exitOnCtrlC: false,
53
- kittyKeyboard: isCapture
54
- ? { mode: "disabled" }
55
- : {
56
- mode: "enabled",
57
- flags: ["disambiguateEscapeCodes"],
58
- },
59
- },
60
- );
61
- await instance.waitUntilExit();
62
- },
63
- );
25
+ // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
26
+ // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
27
+ // capture. Use "disabled" mode in capture to keep text input working;
28
+ // captures that need Tab/Escape should use the `-p` prompt flag or
29
+ // a /slash command typed as text instead.
30
+ const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
31
+ const instance = render(
32
+ React.createElement(App, {
33
+ projectDir: dir,
34
+ threadId: opts.threadId,
35
+ initialPrompt: opts.prompt,
36
+ }),
37
+ {
38
+ exitOnCtrlC: false,
39
+ kittyKeyboard: isCapture
40
+ ? { mode: "disabled" }
41
+ : {
42
+ mode: "enabled",
43
+ flags: ["disambiguateEscapeCodes"],
44
+ },
45
+ },
46
+ );
47
+ await instance.waitUntilExit();
48
+ });
64
49
  }
@@ -6,8 +6,8 @@ import { deleteAllDaemonState } from "../db/daemon-state.ts";
6
6
  import { deleteAllSchedules } from "../db/schedules.ts";
7
7
  import { deleteAllTasks } from "../db/tasks.ts";
8
8
  import { deleteAllThreads } from "../db/threads.ts";
9
+ import { listWorkers } from "../db/workers.ts";
9
10
  import { logger } from "../utils/logger.ts";
10
- import { getDaemonStatus } from "../utils/pid.ts";
11
11
  import { withDb } from "./with-db.ts";
12
12
 
13
13
  type NukeScope = "context" | "tasks" | "schedules" | "threads" | "all";
@@ -49,12 +49,15 @@ function printDryRun(scope: NukeScope, counts: Record<string, number>) {
49
49
  );
50
50
  }
51
51
 
52
- async function ensureDaemonStopped(dir: string): Promise<boolean> {
53
- const status = await getDaemonStatus(dir);
54
- if (status) {
52
+ async function ensureNoRunningWorkers(conn: DbConnection): Promise<boolean> {
53
+ const running = await listWorkers(conn, { status: "running" });
54
+ if (running.length > 0) {
55
55
  logger.error(
56
- `Daemon is running (PID ${status.pid}). Stop it first: botholomew daemon stop`,
56
+ `${running.length} worker(s) running. Stop them first: botholomew worker stop <id>`,
57
57
  );
58
+ for (const w of running) {
59
+ logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
60
+ }
58
61
  return false;
59
62
  }
60
63
  return true;
@@ -101,8 +104,8 @@ function registerScope(
101
104
  .description(description)
102
105
  .option("-y, --yes", "confirm the deletion (required)")
103
106
  .action((opts) =>
104
- withDb(program, async (conn, dir) => {
105
- if (!(await ensureDaemonStopped(dir))) {
107
+ withDb(program, async (conn) => {
108
+ if (!(await ensureNoRunningWorkers(conn))) {
106
109
  process.exit(1);
107
110
  }
108
111
  const tables = TABLES_BY_SCOPE[scope];
@@ -138,7 +141,7 @@ export function registerNukeCommand(program: Command) {
138
141
  program,
139
142
  nuke,
140
143
  "threads",
141
- "Erase all threads and interactions (daemon + chat history)",
144
+ "Erase all threads and interactions (worker + chat history)",
142
145
  );
143
146
  registerScope(
144
147
  program,
@@ -134,7 +134,7 @@ export function registerScheduleCommand(program: Command) {
134
134
  }
135
135
 
136
136
  // Lazy import to avoid loading LLM deps for non-trigger commands
137
- const { evaluateSchedule } = await import("../daemon/schedules.ts");
137
+ const { evaluateSchedule } = await import("../worker/schedules.ts");
138
138
  const { loadConfig } = await import("../config/loader.ts");
139
139
  const { createTask } = await import("../db/tasks.ts");
140
140
  const { markScheduleRun } = await import("../db/schedules.ts");
@@ -18,7 +18,7 @@ export function registerThreadCommand(program: Command) {
18
18
  thread
19
19
  .command("list")
20
20
  .description("List threads")
21
- .option("-t, --type <type>", "filter by type (daemon_tick, chat_session)")
21
+ .option("-t, --type <type>", "filter by type (worker_tick, chat_session)")
22
22
  .option("-l, --limit <n>", "max number of threads", Number.parseInt)
23
23
  .option("-o, --offset <n>", "skip first N threads", Number.parseInt)
24
24
  .action((opts) =>
@@ -156,7 +156,7 @@ export function registerThreadCommand(program: Command) {
156
156
 
157
157
  function typeColor(type: Thread["type"]): string {
158
158
  switch (type) {
159
- case "daemon_tick":
159
+ case "worker_tick":
160
160
  return ansis.magenta(type);
161
161
  case "chat_session":
162
162
  return ansis.cyan(type);
@@ -7,7 +7,7 @@ import { migrate } from "../db/schema.ts";
7
7
  /**
8
8
  * Open a migrated DB connection from the CLI --dir flag, run the callback,
9
9
  * and guarantee the connection is closed afterward. Retries on lock
10
- * conflicts so CLI invocations cooperate with a running daemon/chat.
10
+ * conflicts so CLI invocations cooperate with running workers or chat.
11
11
  */
12
12
  export async function withDb<T>(
13
13
  program: Command,
@@ -0,0 +1,231 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import { loadConfig } from "../config/loader.ts";
4
+ import {
5
+ getWorker,
6
+ listWorkers,
7
+ markWorkerDead,
8
+ markWorkerStopped,
9
+ pruneStoppedWorkers,
10
+ reapDeadWorkers,
11
+ WORKER_STATUSES,
12
+ type Worker,
13
+ } from "../db/workers.ts";
14
+ import { logger } from "../utils/logger.ts";
15
+ import { withDb } from "./with-db.ts";
16
+
17
+ function formatAge(from: Date, to = new Date()): string {
18
+ const secs = Math.max(0, Math.floor((to.getTime() - from.getTime()) / 1000));
19
+ if (secs < 60) return `${secs}s ago`;
20
+ const mins = Math.floor(secs / 60);
21
+ if (mins < 60) return `${mins}m ago`;
22
+ const hours = Math.floor(mins / 60);
23
+ if (hours < 24) return `${hours}h ago`;
24
+ const days = Math.floor(hours / 24);
25
+ return `${days}d ago`;
26
+ }
27
+
28
+ function statusColor(status: Worker["status"]): string {
29
+ switch (status) {
30
+ case "running":
31
+ return ansis.green(status);
32
+ case "stopped":
33
+ return ansis.dim(status);
34
+ case "dead":
35
+ return ansis.red(status);
36
+ }
37
+ }
38
+
39
+ function printWorker(w: Worker) {
40
+ const short = w.id.slice(0, 8);
41
+ const lines = [
42
+ `${ansis.bold(short)} pid=${w.pid} mode=${w.mode} ${statusColor(w.status)} host=${w.hostname}`,
43
+ ` started: ${w.started_at.toISOString()} (${formatAge(w.started_at)})`,
44
+ ` heartbeat: ${w.last_heartbeat_at.toISOString()} (${formatAge(w.last_heartbeat_at)})`,
45
+ ];
46
+ if (w.task_id) lines.push(` task: ${w.task_id}`);
47
+ if (w.stopped_at) lines.push(` stopped: ${w.stopped_at.toISOString()}`);
48
+ console.log(lines.join("\n"));
49
+ }
50
+
51
+ export function registerWorkerCommand(program: Command) {
52
+ const worker = program
53
+ .command("worker")
54
+ .description("Manage background workers that claim and run tasks");
55
+
56
+ worker
57
+ .command("run")
58
+ .description(
59
+ "Run a worker in the foreground. One-shot by default: claims one task and exits. Use --persist for a long-running tick loop.",
60
+ )
61
+ .option("--persist", "keep running, looping over the tick cycle", false)
62
+ .option(
63
+ "--task-id <id>",
64
+ "run exactly this task (implies one-shot; incompatible with --persist)",
65
+ )
66
+ .option("--no-eval-schedules", "skip schedule evaluation this run")
67
+ .action(
68
+ async (opts: {
69
+ persist?: boolean;
70
+ taskId?: string;
71
+ evalSchedules?: boolean;
72
+ }) => {
73
+ if (opts.persist && opts.taskId) {
74
+ logger.error("--persist and --task-id are mutually exclusive.");
75
+ process.exit(1);
76
+ }
77
+ const dir = program.opts().dir;
78
+ const { startWorker } = await import("../worker/index.ts");
79
+ await startWorker(dir, {
80
+ foreground: true,
81
+ mode: opts.persist ? "persist" : "once",
82
+ taskId: opts.taskId,
83
+ evalSchedules: opts.evalSchedules,
84
+ });
85
+ },
86
+ );
87
+
88
+ worker
89
+ .command("start")
90
+ .description("Spawn a worker as a detached background process")
91
+ .option("--persist", "keep running, looping over the tick cycle", false)
92
+ .option("--task-id <id>", "run exactly this task (implies one-shot)")
93
+ .action(async (opts: { persist?: boolean; taskId?: string }) => {
94
+ if (opts.persist && opts.taskId) {
95
+ logger.error("--persist and --task-id are mutually exclusive.");
96
+ process.exit(1);
97
+ }
98
+ const dir = program.opts().dir;
99
+ const { spawnWorker } = await import("../worker/spawn.ts");
100
+ await spawnWorker(dir, {
101
+ mode: opts.persist ? "persist" : "once",
102
+ taskId: opts.taskId,
103
+ });
104
+ });
105
+
106
+ worker
107
+ .command("list")
108
+ .description("List workers registered in this project's database")
109
+ .option(
110
+ "-s, --status <status>",
111
+ `filter by status (${WORKER_STATUSES.join("|")})`,
112
+ )
113
+ .option("-l, --limit <n>", "max number of workers", Number.parseInt)
114
+ .option("-o, --offset <n>", "skip first N workers", Number.parseInt)
115
+ .action(
116
+ (opts: { status?: Worker["status"]; limit?: number; offset?: number }) =>
117
+ withDb(program, async (conn) => {
118
+ if (opts.status && !WORKER_STATUSES.includes(opts.status)) {
119
+ logger.error(
120
+ `Unknown status: ${opts.status}. Use one of: ${WORKER_STATUSES.join(", ")}`,
121
+ );
122
+ process.exit(1);
123
+ }
124
+ const workers = await listWorkers(conn, {
125
+ status: opts.status,
126
+ limit: opts.limit,
127
+ offset: opts.offset,
128
+ });
129
+ if (workers.length === 0) {
130
+ logger.dim("No workers found.");
131
+ return;
132
+ }
133
+ for (const w of workers) {
134
+ printWorker(w);
135
+ console.log("");
136
+ }
137
+ }),
138
+ );
139
+
140
+ worker
141
+ .command("status <id>")
142
+ .description("Show details for a single worker")
143
+ .action((id: string) =>
144
+ withDb(program, async (conn) => {
145
+ const w = await getWorker(conn, id);
146
+ if (!w) {
147
+ logger.error(`No worker found with id ${id}.`);
148
+ process.exit(1);
149
+ }
150
+ printWorker(w);
151
+ }),
152
+ );
153
+
154
+ worker
155
+ .command("stop <id>")
156
+ .description("SIGTERM the worker's process (graceful) and mark stopped")
157
+ .action((id: string) =>
158
+ withDb(program, async (conn) => {
159
+ const w = await getWorker(conn, id);
160
+ if (!w) {
161
+ logger.error(`No worker found with id ${id}.`);
162
+ process.exit(1);
163
+ }
164
+ signalWorker(w, "SIGTERM");
165
+ await markWorkerStopped(conn, id);
166
+ logger.success(`Worker ${id} signaled (SIGTERM) and marked stopped.`);
167
+ }),
168
+ );
169
+
170
+ worker
171
+ .command("kill <id>")
172
+ .description("SIGKILL the worker's process and mark dead")
173
+ .action((id: string) =>
174
+ withDb(program, async (conn) => {
175
+ const w = await getWorker(conn, id);
176
+ if (!w) {
177
+ logger.error(`No worker found with id ${id}.`);
178
+ process.exit(1);
179
+ }
180
+ signalWorker(w, "SIGKILL");
181
+ await markWorkerDead(conn, id);
182
+ logger.success(`Worker ${id} killed (SIGKILL) and marked dead.`);
183
+ }),
184
+ );
185
+
186
+ worker
187
+ .command("reap")
188
+ .description(
189
+ "Mark stale workers dead (releasing their tasks/schedule claims) and prune cleanly-stopped workers older than the retention window",
190
+ )
191
+ .action(() =>
192
+ withDb(program, async (conn, dir) => {
193
+ const config = await loadConfig(dir);
194
+ const reaped = await reapDeadWorkers(
195
+ conn,
196
+ config.worker_dead_after_seconds,
197
+ );
198
+ if (reaped.length === 0) {
199
+ logger.dim("No stale workers to reap.");
200
+ } else {
201
+ logger.success(
202
+ `Reaped ${reaped.length} worker(s): ${reaped.join(", ")}`,
203
+ );
204
+ }
205
+ const pruned = await pruneStoppedWorkers(
206
+ conn,
207
+ config.worker_stopped_retention_seconds,
208
+ );
209
+ if (pruned.length > 0) {
210
+ logger.success(
211
+ `Pruned ${pruned.length} stopped worker(s) older than retention window.`,
212
+ );
213
+ }
214
+ }),
215
+ );
216
+ }
217
+
218
+ function signalWorker(w: Worker, signal: "SIGTERM" | "SIGKILL"): void {
219
+ if (w.status !== "running") {
220
+ logger.warn(
221
+ `Worker ${w.id} already ${w.status}; signaling PID ${w.pid} anyway.`,
222
+ );
223
+ }
224
+ try {
225
+ process.kill(w.pid, signal);
226
+ } catch (err) {
227
+ logger.warn(
228
+ `Could not send ${signal} to PID ${w.pid}: ${err instanceof Error ? err.message : err}. Marking DB state only.`,
229
+ );
230
+ }
231
+ }