botholomew 0.7.12 → 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 (55) 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/ContextPanel.tsx +5 -1
  28. package/src/tui/components/HelpPanel.tsx +5 -5
  29. package/src/tui/components/StatusBar.tsx +22 -18
  30. package/src/tui/components/TabBar.tsx +3 -2
  31. package/src/tui/components/ThreadPanel.tsx +7 -7
  32. package/src/tui/components/WorkerPanel.tsx +207 -0
  33. package/src/utils/title.ts +1 -1
  34. package/src/worker/heartbeat.ts +78 -0
  35. package/src/worker/index.ts +200 -0
  36. package/src/{daemon → worker}/llm.ts +5 -5
  37. package/src/{daemon → worker}/prompt.ts +2 -2
  38. package/src/worker/run.ts +26 -0
  39. package/src/{daemon → worker}/schedules.ts +30 -2
  40. package/src/worker/spawn.ts +48 -0
  41. package/src/{daemon → worker}/tick.ts +93 -35
  42. package/src/commands/daemon.ts +0 -152
  43. package/src/daemon/ensure-running.ts +0 -16
  44. package/src/daemon/healthcheck.ts +0 -47
  45. package/src/daemon/index.ts +0 -106
  46. package/src/daemon/run.ts +0 -14
  47. package/src/daemon/spawn.ts +0 -38
  48. package/src/daemon/watchdog.ts +0 -306
  49. package/src/utils/pid.ts +0 -55
  50. package/src/utils/project-registry.ts +0 -48
  51. /package/src/{daemon → worker}/context.ts +0 -0
  52. /package/src/{daemon → worker}/fake-llm.ts +0 -0
  53. /package/src/{daemon → worker}/fake-mcp.ts +0 -0
  54. /package/src/{daemon → worker}/large-results.ts +0 -0
  55. /package/src/{daemon → worker}/llm-client.ts +0 -0
@@ -9,6 +9,12 @@ export interface BotholomewConfig {
9
9
  max_tick_duration_seconds?: number;
10
10
  system_prompt_override?: string;
11
11
  max_turns?: number;
12
+ worker_heartbeat_interval_seconds?: number;
13
+ worker_dead_after_seconds?: number;
14
+ worker_reap_interval_seconds?: number;
15
+ worker_stopped_retention_seconds?: number;
16
+ schedule_min_interval_seconds?: number;
17
+ schedule_claim_stale_seconds?: number;
12
18
  }
13
19
 
14
20
  export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
@@ -22,4 +28,10 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
22
28
  max_tick_duration_seconds: 120,
23
29
  system_prompt_override: "",
24
30
  max_turns: 0,
31
+ worker_heartbeat_interval_seconds: 15,
32
+ worker_dead_after_seconds: 60,
33
+ worker_reap_interval_seconds: 30,
34
+ worker_stopped_retention_seconds: 3600,
35
+ schedule_min_interval_seconds: 60,
36
+ schedule_claim_stale_seconds: 300,
25
37
  };
package/src/constants.ts CHANGED
@@ -13,19 +13,14 @@ export const DEFAULTS = {
13
13
  UPDATE_CHECK_TIMEOUT_MS: 5_000,
14
14
  } as const;
15
15
  export const DB_FILENAME = "data.duckdb";
16
- export const PID_FILENAME = "daemon.pid";
17
- export const LOG_FILENAME = "daemon.log";
16
+ export const LOG_FILENAME = "worker.log";
18
17
  export const CONFIG_FILENAME = "config.json";
19
18
  export const MCPX_DIR = "mcpx";
20
19
  export const SKILLS_DIR = "skills";
21
20
  export const MCPX_SERVERS_FILENAME = "servers.json";
22
21
  export const EMBEDDING_DIMENSION = 1536;
23
22
  export const EMBEDDING_MODEL = "text-embedding-3-small";
24
-
25
- export const LAUNCHD_LABEL_PREFIX = "com.botholomew.";
26
- export const SYSTEMD_UNIT_PREFIX = "botholomew-";
27
23
  export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
28
- export const WATCHDOG_LOG_FILENAME = "watchdog.log";
29
24
 
30
25
  export function getBotholomewDir(projectDir: string): string {
31
26
  return join(projectDir, BOTHOLOMEW_DIR);
@@ -35,10 +30,6 @@ export function getDbPath(projectDir: string): string {
35
30
  return join(projectDir, BOTHOLOMEW_DIR, DB_FILENAME);
36
31
  }
37
32
 
38
- export function getPidPath(projectDir: string): string {
39
- return join(projectDir, BOTHOLOMEW_DIR, PID_FILENAME);
40
- }
41
-
42
33
  export function getLogPath(projectDir: string): string {
43
34
  return join(projectDir, BOTHOLOMEW_DIR, LOG_FILENAME);
44
35
  }
@@ -54,20 +45,3 @@ export function getMcpxDir(projectDir: string): string {
54
45
  export function getSkillsDir(projectDir: string): string {
55
46
  return join(projectDir, BOTHOLOMEW_DIR, SKILLS_DIR);
56
47
  }
57
-
58
- export function getWatchdogLogPath(projectDir: string): string {
59
- return join(projectDir, BOTHOLOMEW_DIR, WATCHDOG_LOG_FILENAME);
60
- }
61
-
62
- /**
63
- * Convert an absolute directory path into a service-name-safe string.
64
- * e.g. "/Users/evan/myproject" → "users-evan-myproject"
65
- */
66
- export function sanitizePathForServiceName(projectDir: string): string {
67
- return projectDir
68
- .toLowerCase()
69
- .replace(/[/\\:]+/g, "-")
70
- .replace(/^-+/, "")
71
- .replace(/-+$/, "")
72
- .replace(/-+/g, "-");
73
- }
@@ -9,6 +9,8 @@ export interface Schedule {
9
9
  frequency: string;
10
10
  last_run_at: Date | null;
11
11
  enabled: boolean;
12
+ claimed_by: string | null;
13
+ claimed_at: Date | null;
12
14
  created_at: Date;
13
15
  updated_at: Date;
14
16
  }
@@ -20,6 +22,8 @@ interface ScheduleRow {
20
22
  frequency: string;
21
23
  last_run_at: string | null;
22
24
  enabled: boolean;
25
+ claimed_by: string | null;
26
+ claimed_at: string | null;
23
27
  created_at: string;
24
28
  updated_at: string;
25
29
  }
@@ -32,6 +36,8 @@ function rowToSchedule(row: ScheduleRow): Schedule {
32
36
  frequency: row.frequency,
33
37
  last_run_at: row.last_run_at ? new Date(row.last_run_at) : null,
34
38
  enabled: !!row.enabled,
39
+ claimed_by: row.claimed_by ?? null,
40
+ claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
35
41
  created_at: new Date(row.created_at),
36
42
  updated_at: new Date(row.updated_at),
37
43
  };
@@ -145,3 +151,63 @@ export async function markScheduleRun(
145
151
  id,
146
152
  );
147
153
  }
154
+
155
+ /**
156
+ * Atomically claim a schedule for evaluation. Returns the schedule if
157
+ * successfully claimed, or null if another worker already holds the claim
158
+ * or the schedule ran too recently to re-evaluate.
159
+ *
160
+ * `staleAfterSeconds`: how long an existing claim is considered still-held
161
+ * before another worker may steal it (protects against crashed claimers).
162
+ * `minIntervalSeconds`: minimum gap since `last_run_at` before re-evaluation.
163
+ */
164
+ export async function claimSchedule(
165
+ db: DbConnection,
166
+ id: string,
167
+ claimedBy: string,
168
+ opts: { staleAfterSeconds: number; minIntervalSeconds: number },
169
+ ): Promise<Schedule | null> {
170
+ const row = await db.queryGet<ScheduleRow>(
171
+ `UPDATE schedules
172
+ SET claimed_by = ?1,
173
+ claimed_at = current_timestamp::VARCHAR
174
+ WHERE id = ?2
175
+ AND enabled = true
176
+ AND (
177
+ claimed_by IS NULL
178
+ OR claimed_at IS NULL
179
+ OR claimed_at::TIMESTAMP
180
+ < current_timestamp - to_seconds(CAST(?3 AS BIGINT))
181
+ )
182
+ AND (
183
+ last_run_at IS NULL
184
+ OR last_run_at::TIMESTAMP
185
+ < current_timestamp - to_seconds(CAST(?4 AS BIGINT))
186
+ )
187
+ RETURNING *`,
188
+ claimedBy,
189
+ id,
190
+ opts.staleAfterSeconds,
191
+ opts.minIntervalSeconds,
192
+ );
193
+ return row ? rowToSchedule(row) : null;
194
+ }
195
+
196
+ /**
197
+ * Release a schedule claim without modifying `last_run_at`. Safe to call
198
+ * even if the claim has already expired — the WHERE guard ensures we only
199
+ * clear our own claim.
200
+ */
201
+ export async function releaseSchedule(
202
+ db: DbConnection,
203
+ id: string,
204
+ claimedBy: string,
205
+ ): Promise<void> {
206
+ await db.queryRun(
207
+ `UPDATE schedules
208
+ SET claimed_by = NULL, claimed_at = NULL
209
+ WHERE id = ?1 AND claimed_by = ?2`,
210
+ id,
211
+ claimedBy,
212
+ );
213
+ }
package/src/db/schema.ts CHANGED
@@ -11,11 +11,9 @@ interface Migration {
11
11
  const sqlDir = join(import.meta.dir, "sql");
12
12
 
13
13
  function loadMigrations(): Migration[] {
14
- const files = readdirSync(sqlDir)
15
- .filter((f) => f.endsWith(".sql"))
16
- .sort();
14
+ const files = readdirSync(sqlDir).filter((f) => f.endsWith(".sql"));
17
15
 
18
- return files.map((file) => {
16
+ const migrations = files.map((file) => {
19
17
  const match = file.match(/^(\d+)-(.+)\.sql$/);
20
18
  if (!match) throw new Error(`Invalid migration filename: ${file}`);
21
19
  const id = match[1];
@@ -27,6 +25,9 @@ function loadMigrations(): Migration[] {
27
25
  sql: readFileSync(join(sqlDir, file), "utf-8"),
28
26
  };
29
27
  });
28
+
29
+ // Sort by numeric id so `12-` runs after `2-`, not between `11-` and `2-`.
30
+ return migrations.sort((a, b) => a.id - b.id);
30
31
  }
31
32
 
32
33
  export async function migrate(db: DbConnection): Promise<void> {
@@ -0,0 +1,66 @@
1
+ -- Worker agents: replaces the PID-file + OS-watchdog single-daemon model
2
+ -- with multiple in-DB registered workers that heartbeat and can be reaped.
3
+
4
+ CREATE TABLE workers (
5
+ id TEXT PRIMARY KEY,
6
+ pid INTEGER NOT NULL,
7
+ hostname TEXT NOT NULL,
8
+ mode TEXT NOT NULL CHECK(mode IN ('persist', 'once')),
9
+ task_id TEXT,
10
+ status TEXT NOT NULL CHECK(status IN ('running', 'stopped', 'dead')),
11
+ started_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
12
+ last_heartbeat_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
13
+ stopped_at TEXT
14
+ );
15
+
16
+ CREATE INDEX idx_workers_status_heartbeat ON workers(status, last_heartbeat_at);
17
+
18
+ -- Schedule claim columns: only one worker evaluates a schedule per window.
19
+ ALTER TABLE schedules ADD COLUMN claimed_by TEXT;
20
+ ALTER TABLE schedules ADD COLUMN claimed_at TEXT;
21
+
22
+ -- Rewrite threads.type values: daemon_tick → worker_tick. The existing
23
+ -- CHECK constraint forbids the new value, so we rebuild both threads and
24
+ -- interactions (whose FK to threads would block a DROP). Dropping the FK
25
+ -- follows the 7-drop_embeddings_fk.sql precedent.
26
+ CREATE TABLE threads_backup AS SELECT * FROM threads;
27
+ CREATE TABLE interactions_backup AS SELECT * FROM interactions;
28
+
29
+ DROP TABLE interactions;
30
+ DROP TABLE threads;
31
+
32
+ CREATE TABLE threads (
33
+ id TEXT PRIMARY KEY,
34
+ type TEXT NOT NULL CHECK(type IN ('worker_tick', 'chat_session')),
35
+ task_id TEXT,
36
+ title TEXT NOT NULL DEFAULT '',
37
+ started_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
38
+ ended_at TEXT,
39
+ metadata TEXT
40
+ );
41
+
42
+ CREATE TABLE interactions (
43
+ id TEXT PRIMARY KEY,
44
+ thread_id TEXT NOT NULL,
45
+ sequence INTEGER NOT NULL,
46
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
47
+ kind TEXT NOT NULL CHECK(kind IN ('message', 'thinking', 'tool_use', 'tool_result', 'context_update', 'status_change')),
48
+ content TEXT NOT NULL,
49
+ tool_name TEXT,
50
+ tool_input TEXT,
51
+ duration_ms INTEGER,
52
+ token_count INTEGER,
53
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
54
+ UNIQUE(thread_id, sequence)
55
+ );
56
+
57
+ INSERT INTO threads
58
+ SELECT id,
59
+ CASE WHEN type = 'daemon_tick' THEN 'worker_tick' ELSE type END,
60
+ task_id, title, started_at, ended_at, metadata
61
+ FROM threads_backup;
62
+
63
+ INSERT INTO interactions SELECT * FROM interactions_backup;
64
+
65
+ DROP TABLE threads_backup;
66
+ DROP TABLE interactions_backup;
package/src/db/tasks.ts CHANGED
@@ -270,7 +270,7 @@ export async function resetStaleTasks(
270
270
 
271
271
  export async function claimNextTask(
272
272
  db: DbConnection,
273
- claimedBy = "daemon",
273
+ claimedBy: string,
274
274
  ): Promise<Task | null> {
275
275
  // Find highest-priority unblocked pending task
276
276
  // Use application-level filtering for blocked_by since DuckDB doesn't have json_each
@@ -321,3 +321,27 @@ export async function claimNextTask(
321
321
 
322
322
  return null;
323
323
  }
324
+
325
+ /**
326
+ * Atomically claim a specific task by id. Returns the task if successfully
327
+ * claimed, or null if the task doesn't exist, is already claimed, or isn't
328
+ * in `pending` state.
329
+ */
330
+ export async function claimSpecificTask(
331
+ db: DbConnection,
332
+ taskId: string,
333
+ claimedBy: string,
334
+ ): Promise<Task | null> {
335
+ const row = await db.queryGet<TaskRow>(
336
+ `UPDATE tasks
337
+ SET status = 'in_progress',
338
+ claimed_by = ?1,
339
+ claimed_at = current_timestamp::VARCHAR,
340
+ updated_at = current_timestamp::VARCHAR
341
+ WHERE id = ?2 AND status = 'pending'
342
+ RETURNING *`,
343
+ claimedBy,
344
+ taskId,
345
+ );
346
+ return row ? rowToTask(row) : null;
347
+ }
package/src/db/threads.ts CHANGED
@@ -4,7 +4,7 @@ import { uuidv7 } from "./uuid.ts";
4
4
 
5
5
  export interface Thread {
6
6
  id: string;
7
- type: "daemon_tick" | "chat_session";
7
+ type: "worker_tick" | "chat_session";
8
8
  task_id: string | null;
9
9
  title: string;
10
10
  started_at: Date;
@@ -0,0 +1,207 @@
1
+ import type { DbConnection } from "./connection.ts";
2
+ import { buildWhereClause, sanitizeInt } from "./query.ts";
3
+
4
+ export const WORKER_MODES = ["persist", "once"] as const;
5
+ export const WORKER_STATUSES = ["running", "stopped", "dead"] as const;
6
+
7
+ export interface Worker {
8
+ id: string;
9
+ pid: number;
10
+ hostname: string;
11
+ mode: (typeof WORKER_MODES)[number];
12
+ task_id: string | null;
13
+ status: (typeof WORKER_STATUSES)[number];
14
+ started_at: Date;
15
+ last_heartbeat_at: Date;
16
+ stopped_at: Date | null;
17
+ }
18
+
19
+ interface WorkerRow {
20
+ id: string;
21
+ pid: number;
22
+ hostname: string;
23
+ mode: string;
24
+ task_id: string | null;
25
+ status: string;
26
+ started_at: string;
27
+ last_heartbeat_at: string;
28
+ stopped_at: string | null;
29
+ }
30
+
31
+ function rowToWorker(row: WorkerRow): Worker {
32
+ return {
33
+ id: row.id,
34
+ pid: row.pid,
35
+ hostname: row.hostname,
36
+ mode: row.mode as Worker["mode"],
37
+ task_id: row.task_id,
38
+ status: row.status as Worker["status"],
39
+ started_at: new Date(row.started_at),
40
+ last_heartbeat_at: new Date(row.last_heartbeat_at),
41
+ stopped_at: row.stopped_at ? new Date(row.stopped_at) : null,
42
+ };
43
+ }
44
+
45
+ export async function registerWorker(
46
+ db: DbConnection,
47
+ params: {
48
+ id: string;
49
+ pid: number;
50
+ hostname: string;
51
+ mode: Worker["mode"];
52
+ taskId?: string | null;
53
+ },
54
+ ): Promise<Worker> {
55
+ const row = await db.queryGet<WorkerRow>(
56
+ `INSERT INTO workers (id, pid, hostname, mode, task_id, status)
57
+ VALUES (?1, ?2, ?3, ?4, ?5, 'running')
58
+ RETURNING *`,
59
+ params.id,
60
+ params.pid,
61
+ params.hostname,
62
+ params.mode,
63
+ params.taskId ?? null,
64
+ );
65
+ if (!row) throw new Error("INSERT did not return a row");
66
+ return rowToWorker(row);
67
+ }
68
+
69
+ export async function heartbeat(db: DbConnection, id: string): Promise<void> {
70
+ await db.queryRun(
71
+ `UPDATE workers
72
+ SET last_heartbeat_at = current_timestamp::VARCHAR
73
+ WHERE id = ?1 AND status = 'running'`,
74
+ id,
75
+ );
76
+ }
77
+
78
+ export async function markWorkerStopped(
79
+ db: DbConnection,
80
+ id: string,
81
+ ): Promise<void> {
82
+ await db.queryRun(
83
+ `UPDATE workers
84
+ SET status = 'stopped',
85
+ stopped_at = current_timestamp::VARCHAR
86
+ WHERE id = ?1 AND status = 'running'`,
87
+ id,
88
+ );
89
+ }
90
+
91
+ export async function markWorkerDead(
92
+ db: DbConnection,
93
+ id: string,
94
+ ): Promise<void> {
95
+ await db.queryRun(
96
+ `UPDATE workers
97
+ SET status = 'dead',
98
+ stopped_at = current_timestamp::VARCHAR
99
+ WHERE id = ?1 AND status = 'running'`,
100
+ id,
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Find running workers whose heartbeat is older than `staleAfterSeconds`,
106
+ * mark them dead, and release any tasks/schedule claims they held back
107
+ * to the pool. Returns the ids of reaped workers.
108
+ */
109
+ export async function reapDeadWorkers(
110
+ db: DbConnection,
111
+ staleAfterSeconds: number,
112
+ ): Promise<string[]> {
113
+ const stale = await db.queryAll<{ id: string }>(
114
+ `UPDATE workers
115
+ SET status = 'dead',
116
+ stopped_at = current_timestamp::VARCHAR
117
+ WHERE status = 'running'
118
+ AND last_heartbeat_at::TIMESTAMP
119
+ < current_timestamp - to_seconds(CAST(?1 AS BIGINT))
120
+ RETURNING id`,
121
+ staleAfterSeconds,
122
+ );
123
+ const reapedIds = stale.map((r) => r.id);
124
+ if (reapedIds.length === 0) return reapedIds;
125
+
126
+ for (const reapedId of reapedIds) {
127
+ await db.queryRun(
128
+ `UPDATE tasks
129
+ SET status = 'pending',
130
+ claimed_by = NULL,
131
+ claimed_at = NULL,
132
+ updated_at = current_timestamp::VARCHAR
133
+ WHERE claimed_by = ?1 AND status = 'in_progress'`,
134
+ reapedId,
135
+ );
136
+ await db.queryRun(
137
+ `UPDATE schedules
138
+ SET claimed_by = NULL,
139
+ claimed_at = NULL
140
+ WHERE claimed_by = ?1`,
141
+ reapedId,
142
+ );
143
+ }
144
+ return reapedIds;
145
+ }
146
+
147
+ /**
148
+ * Delete cleanly-stopped workers (status='stopped') whose `stopped_at` is
149
+ * older than `afterSeconds`. Dead workers are intentionally left alone —
150
+ * they're forensic evidence that something crashed.
151
+ * Returns the ids that were pruned.
152
+ */
153
+ export async function pruneStoppedWorkers(
154
+ db: DbConnection,
155
+ afterSeconds: number,
156
+ ): Promise<string[]> {
157
+ const rows = await db.queryAll<{ id: string }>(
158
+ `DELETE FROM workers
159
+ WHERE status = 'stopped'
160
+ AND stopped_at IS NOT NULL
161
+ AND stopped_at::TIMESTAMP
162
+ < current_timestamp - to_seconds(CAST(?1 AS BIGINT))
163
+ RETURNING id`,
164
+ afterSeconds,
165
+ );
166
+ return rows.map((r) => r.id);
167
+ }
168
+
169
+ export async function listWorkers(
170
+ db: DbConnection,
171
+ filters?: {
172
+ status?: Worker["status"];
173
+ limit?: number;
174
+ offset?: number;
175
+ },
176
+ ): Promise<Worker[]> {
177
+ const { where, params } = buildWhereClause([["status", filters?.status]]);
178
+ const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
179
+ const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
180
+
181
+ const rows = await db.queryAll<WorkerRow>(
182
+ `SELECT * FROM workers ${where}
183
+ ORDER BY started_at DESC, id DESC
184
+ ${limit} ${offset}`,
185
+ ...params,
186
+ );
187
+ return rows.map(rowToWorker);
188
+ }
189
+
190
+ export async function getWorker(
191
+ db: DbConnection,
192
+ id: string,
193
+ ): Promise<Worker | null> {
194
+ const row = await db.queryGet<WorkerRow>(
195
+ "SELECT * FROM workers WHERE id = ?1",
196
+ id,
197
+ );
198
+ return row ? rowToWorker(row) : null;
199
+ }
200
+
201
+ export async function deleteWorker(
202
+ db: DbConnection,
203
+ id: string,
204
+ ): Promise<boolean> {
205
+ const result = await db.queryRun("DELETE FROM workers WHERE id = ?1", id);
206
+ return result.changes > 0;
207
+ }
package/src/init/index.ts CHANGED
@@ -77,7 +77,9 @@ export async function initProject(
77
77
  logger.dim("Next steps:");
78
78
  logger.dim(" 1. Set ANTHROPIC_API_KEY or add it to .botholomew/config.json");
79
79
  logger.dim(" 2. Run 'botholomew task add' to create your first task");
80
- logger.dim(" 3. Run 'botholomew daemon start' to start the daemon");
80
+ logger.dim(
81
+ " 3. Run 'botholomew worker start --persist' to start a background worker",
82
+ );
81
83
  }
82
84
 
83
85
  async function updateGitignore(projectDir: string): Promise<void> {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { readLargeResultPage } from "../../daemon/large-results.ts";
2
+ import { readLargeResultPage } from "../../worker/large-results.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
- import { fakeMcpExec, isCaptureMode } from "../../daemon/fake-mcp.ts";
3
2
  import { formatCallToolResult } from "../../mcpx/client.ts";
3
+ import { fakeMcpExec, isCaptureMode } from "../../worker/fake-mcp.ts";
4
4
  import type { ToolDefinition } from "../tool.ts";
5
5
 
6
6
  const inputSchema = z.object({
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { fakeMcpSearch, isCaptureMode } from "../../daemon/fake-mcp.ts";
2
+ import { fakeMcpSearch, isCaptureMode } from "../../worker/fake-mcp.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -43,6 +43,8 @@ import { waitTaskTool } from "./task/wait.ts";
43
43
  import { listThreadsTool } from "./thread/list.ts";
44
44
  import { viewThreadTool } from "./thread/view.ts";
45
45
  import { registerTool } from "./tool.ts";
46
+ // Worker tools
47
+ import { spawnWorkerTool } from "./worker/spawn.ts";
46
48
 
47
49
  export function registerAllTools(): void {
48
50
  // Task
@@ -91,4 +93,7 @@ export function registerAllTools(): void {
91
93
  registerTool(mcpSearchTool);
92
94
  registerTool(mcpInfoTool);
93
95
  registerTool(mcpExecTool);
96
+
97
+ // Worker
98
+ registerTool(spawnWorkerTool);
94
99
  }
@@ -4,7 +4,7 @@ import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
6
  type: z
7
- .enum(["daemon_tick", "chat_session"])
7
+ .enum(["worker_tick", "chat_session"])
8
8
  .optional()
9
9
  .describe("Filter by thread type"),
10
10
  limit: z.number().optional().describe("Max number of threads to return"),
@@ -27,7 +27,7 @@ const outputSchema = z.object({
27
27
 
28
28
  export const listThreadsTool = {
29
29
  name: "list_threads",
30
- description: "List conversation threads (daemon ticks or chat sessions).",
30
+ description: "List conversation threads (worker ticks or chat sessions).",
31
31
  group: "thread",
32
32
  inputSchema,
33
33
  outputSchema,
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ import { spawnWorker } from "../../worker/spawn.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ task_id: z
7
+ .string()
8
+ .optional()
9
+ .describe(
10
+ "Specific task ID to run. If omitted, the worker claims the next eligible task from the queue.",
11
+ ),
12
+ persist: z
13
+ .boolean()
14
+ .optional()
15
+ .describe(
16
+ "If true, spawn a long-running worker that loops over the tick cycle. Defaults to false (one-shot).",
17
+ ),
18
+ });
19
+
20
+ const outputSchema = z.object({
21
+ worker_pid: z.number(),
22
+ mode: z.enum(["once", "persist"]),
23
+ message: z.string(),
24
+ is_error: z.boolean(),
25
+ });
26
+
27
+ export const spawnWorkerTool = {
28
+ name: "spawn_worker",
29
+ description:
30
+ "Spawn a background worker to run a task without blocking this chat. One-shot by default (claims one task and exits). Use for work the user wants executed now rather than simply queued.",
31
+ group: "worker",
32
+ inputSchema,
33
+ outputSchema,
34
+ execute: async (input, ctx) => {
35
+ const mode = input.persist ? "persist" : "once";
36
+ const { pid } = await spawnWorker(ctx.projectDir, {
37
+ mode,
38
+ taskId: input.task_id,
39
+ });
40
+ const target = input.task_id
41
+ ? `task ${input.task_id}`
42
+ : "next eligible task";
43
+ return {
44
+ worker_pid: pid,
45
+ mode,
46
+ message: `Spawned ${mode} worker (pid ${pid}) for ${target}.`,
47
+ is_error: false,
48
+ };
49
+ },
50
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;