botholomew 0.7.8 → 0.7.9

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
@@ -128,6 +128,8 @@ Everything the agent can touch is here. No surprises.
128
128
  | `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
129
129
  | `botholomew upgrade` | Self-update |
130
130
 
131
+ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
132
+
131
133
  ---
132
134
 
133
135
  ## How it works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
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
@@ -4,6 +4,7 @@ import type {
4
4
  ToolResultBlockParam,
5
5
  ToolUseBlock,
6
6
  } from "@anthropic-ai/sdk/resources/messages";
7
+ import type { McpxClient } from "@evantahler/mcpx";
7
8
  import type { BotholomewConfig } from "../config/schemas.ts";
8
9
  import { embedSingle } from "../context/embedder.ts";
9
10
  import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
@@ -13,7 +14,7 @@ import {
13
14
  extractKeywords,
14
15
  loadPersistentContext,
15
16
  } from "../daemon/prompt.ts";
16
- import type { DbConnection } from "../db/connection.ts";
17
+ import { withDb } from "../db/connection.ts";
17
18
  import { hybridSearch } from "../db/embeddings.ts";
18
19
  import { logInteraction } from "../db/threads.ts";
19
20
  import { registerAllTools } from "../tools/registry.ts";
@@ -60,7 +61,7 @@ export async function buildChatSystemPrompt(
60
61
  projectDir: string,
61
62
  options?: {
62
63
  keywordSource?: string;
63
- conn?: DbConnection;
64
+ dbPath?: string;
64
65
  config?: Required<BotholomewConfig>;
65
66
  },
66
67
  ): Promise<string> {
@@ -74,12 +75,14 @@ export async function buildChatSystemPrompt(
74
75
  parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
75
76
 
76
77
  // Relevant context from embeddings search
77
- const conn = options?.conn;
78
+ const dbPath = options?.dbPath;
78
79
  const config = options?.config;
79
- if (conn && config?.openai_api_key && keywordSource) {
80
+ if (dbPath && config?.openai_api_key && keywordSource) {
80
81
  try {
81
82
  const queryVec = await embedSingle(keywordSource, config);
82
- const results = await hybridSearch(conn, keywordSource, queryVec, 5);
83
+ const results = await withDb(dbPath, (conn) =>
84
+ hybridSearch(conn, keywordSource, queryVec, 5),
85
+ );
83
86
 
84
87
  if (results.length > 0) {
85
88
  parts.push("## Relevant Context");
@@ -160,13 +163,20 @@ export async function runChatTurn(input: {
160
163
  messages: MessageParam[];
161
164
  projectDir: string;
162
165
  config: Required<BotholomewConfig>;
163
- conn: DbConnection;
166
+ dbPath: string;
164
167
  threadId: string;
165
- toolCtx: ToolContext;
168
+ mcpxClient: McpxClient | null;
166
169
  callbacks: ChatTurnCallbacks;
167
170
  }): Promise<void> {
168
- const { messages, projectDir, config, conn, threadId, toolCtx, callbacks } =
169
- input;
171
+ const {
172
+ messages,
173
+ projectDir,
174
+ config,
175
+ dbPath,
176
+ threadId,
177
+ mcpxClient,
178
+ callbacks,
179
+ } = input;
170
180
 
171
181
  const client = new Anthropic({
172
182
  apiKey: config.anthropic_api_key || undefined,
@@ -190,7 +200,7 @@ export async function runChatTurn(input: {
190
200
  const keywordSource = findLastUserText(messages);
191
201
  const systemPrompt = await buildChatSystemPrompt(projectDir, {
192
202
  keywordSource,
193
- conn,
203
+ dbPath,
194
204
  config,
195
205
  });
196
206
 
@@ -230,13 +240,15 @@ export async function runChatTurn(input: {
230
240
 
231
241
  // Log assistant text
232
242
  if (assistantText) {
233
- await logInteraction(conn, threadId, {
234
- role: "assistant",
235
- kind: "message",
236
- content: assistantText,
237
- durationMs,
238
- tokenCount,
239
- });
243
+ await withDb(dbPath, (conn) =>
244
+ logInteraction(conn, threadId, {
245
+ role: "assistant",
246
+ kind: "message",
247
+ content: assistantText,
248
+ durationMs,
249
+ tokenCount,
250
+ }),
251
+ );
240
252
  }
241
253
 
242
254
  // Check for tool calls
@@ -260,20 +272,29 @@ export async function runChatTurn(input: {
260
272
  callbacks.onToolStart(toolUse.id, toolUse.name, toolInput);
261
273
  }
262
274
 
263
- await logInteraction(conn, threadId, {
264
- role: "assistant",
265
- kind: "tool_use",
266
- content: `Calling ${toolUse.name}`,
267
- toolName: toolUse.name,
268
- toolInput,
269
- });
275
+ await withDb(dbPath, (conn) =>
276
+ logInteraction(conn, threadId, {
277
+ role: "assistant",
278
+ kind: "tool_use",
279
+ content: `Calling ${toolUse.name}`,
280
+ toolName: toolUse.name,
281
+ toolInput,
282
+ }),
283
+ );
270
284
  }
271
285
 
272
- // Execute all tools in parallel
286
+ // Execute all tools in parallel. Each tool call opens its own short-lived
287
+ // connection; parallel calls share the process-local DuckDB instance and
288
+ // release the file lock as soon as the last one finishes.
273
289
  const execResults = await Promise.all(
274
290
  toolUseBlocks.map(async (toolUse) => {
275
291
  const start = Date.now();
276
- const result = await executeChatToolCall(toolUse, toolCtx);
292
+ const result = await executeChatToolCall(toolUse, {
293
+ dbPath,
294
+ projectDir,
295
+ config,
296
+ mcpxClient,
297
+ });
277
298
  const durationMs = Date.now() - start;
278
299
  const stored = maybeStoreResult(toolUse.name, result.output);
279
300
  const meta: ToolEndMeta | undefined = stored.stored
@@ -293,13 +314,15 @@ export async function runChatTurn(input: {
293
314
  // Log results and collect tool_result messages
294
315
  const toolResults: ToolResultBlockParam[] = [];
295
316
  for (const { toolUse, result, durationMs, stored } of execResults) {
296
- await logInteraction(conn, threadId, {
297
- role: "tool",
298
- kind: "tool_result",
299
- content: result.output,
300
- toolName: toolUse.name,
301
- durationMs,
302
- });
317
+ await withDb(dbPath, (conn) =>
318
+ logInteraction(conn, threadId, {
319
+ role: "tool",
320
+ kind: "tool_result",
321
+ content: result.output,
322
+ toolName: toolUse.name,
323
+ durationMs,
324
+ }),
325
+ );
303
326
 
304
327
  toolResults.push({
305
328
  type: "tool_result",
@@ -314,9 +337,16 @@ export async function runChatTurn(input: {
314
337
  }
315
338
  }
316
339
 
340
+ interface ChatToolCallCtx {
341
+ dbPath: string;
342
+ projectDir: string;
343
+ config: Required<BotholomewConfig>;
344
+ mcpxClient: McpxClient | null;
345
+ }
346
+
317
347
  async function executeChatToolCall(
318
348
  toolUse: ToolUseBlock,
319
- ctx: ToolContext,
349
+ baseCtx: ChatToolCallCtx,
320
350
  ): Promise<{ output: string; isError: boolean }> {
321
351
  const tool = getTool(toolUse.name);
322
352
  if (!tool) return { output: `Unknown tool: ${toolUse.name}`, isError: true };
@@ -335,7 +365,10 @@ async function executeChatToolCall(
335
365
  }
336
366
 
337
367
  try {
338
- const result = await tool.execute(parsed.data, ctx);
368
+ const result = await withDb(baseCtx.dbPath, (conn) => {
369
+ const ctx: ToolContext = { ...baseCtx, conn };
370
+ return tool.execute(parsed.data, ctx);
371
+ });
339
372
  const isError =
340
373
  typeof result === "object" && result !== null && "is_error" in result
341
374
  ? (result as { is_error: boolean }).is_error
@@ -2,8 +2,7 @@ import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
2
2
  import { loadConfig } from "../config/loader.ts";
3
3
  import type { BotholomewConfig } from "../config/schemas.ts";
4
4
  import { getDbPath } from "../constants.ts";
5
- import type { DbConnection } from "../db/connection.ts";
6
- import { getConnection } from "../db/connection.ts";
5
+ import { withDb } from "../db/connection.ts";
7
6
  import { migrate } from "../db/schema.ts";
8
7
  import {
9
8
  createThread,
@@ -15,18 +14,18 @@ import {
15
14
  import { createMcpxClient } from "../mcpx/client.ts";
16
15
  import { loadSkills } from "../skills/loader.ts";
17
16
  import type { SkillDefinition } from "../skills/parser.ts";
18
- import type { ToolContext } from "../tools/tool.ts";
19
17
  import { generateThreadTitle } from "../utils/title.ts";
20
18
  import { type ChatTurnCallbacks, runChatTurn } from "./agent.ts";
21
19
 
22
20
  export interface ChatSession {
23
- conn: DbConnection;
21
+ dbPath: string;
24
22
  threadId: string;
25
23
  projectDir: string;
26
24
  config: Required<BotholomewConfig>;
27
25
  messages: MessageParam[];
28
- toolCtx: ToolContext;
29
26
  skills: Map<string, SkillDefinition>;
27
+ // biome-ignore lint/suspicious/noExplicitAny: mcpx client
28
+ mcpxClient: any;
30
29
  cleanup: () => Promise<void>;
31
30
  }
32
31
 
@@ -42,21 +41,22 @@ export async function startChatSession(
42
41
  );
43
42
  }
44
43
 
45
- const conn = await getConnection(getDbPath(projectDir));
46
- await migrate(conn);
44
+ const dbPath = getDbPath(projectDir);
45
+ await withDb(dbPath, (conn) => migrate(conn));
47
46
 
48
47
  let threadId: string;
49
48
  const messages: MessageParam[] = [];
50
49
 
51
50
  if (existingThreadId) {
52
51
  // Resume existing thread
53
- const result = await getThread(conn, existingThreadId);
52
+ const result = await withDb(dbPath, (conn) =>
53
+ getThread(conn, existingThreadId),
54
+ );
54
55
  if (!result) {
55
- conn.close();
56
56
  throw new Error(`Thread not found: ${existingThreadId}`);
57
57
  }
58
58
  threadId = existingThreadId;
59
- await reopenThread(conn, threadId);
59
+ await withDb(dbPath, (conn) => reopenThread(conn, threadId));
60
60
 
61
61
  // Rebuild message history from interactions
62
62
  let firstUserMessage: string | undefined;
@@ -72,34 +72,29 @@ export async function startChatSession(
72
72
 
73
73
  // Backfill title for threads that still have the default
74
74
  if (result.thread.title === "New chat" && firstUserMessage) {
75
- void generateThreadTitle(config, conn, threadId, firstUserMessage);
75
+ void generateThreadTitle(config, dbPath, threadId, firstUserMessage);
76
76
  }
77
77
  } else {
78
- threadId = await createThread(conn, "chat_session", undefined, "New chat");
78
+ threadId = await withDb(dbPath, (conn) =>
79
+ createThread(conn, "chat_session", undefined, "New chat"),
80
+ );
79
81
  }
80
82
 
81
83
  const mcpxClient = await createMcpxClient(projectDir);
82
84
  const skills = await loadSkills(projectDir);
83
85
 
84
- const toolCtx: ToolContext = {
85
- conn,
86
- projectDir,
87
- config,
88
- mcpxClient,
89
- };
90
-
91
86
  const cleanup = async () => {
92
87
  await mcpxClient?.close();
93
88
  };
94
89
 
95
90
  return {
96
- conn,
91
+ dbPath,
97
92
  threadId,
98
93
  projectDir,
99
94
  config,
100
95
  messages,
101
- toolCtx,
102
96
  skills,
97
+ mcpxClient,
103
98
  cleanup,
104
99
  };
105
100
  }
@@ -110,11 +105,13 @@ export async function sendMessage(
110
105
  callbacks: ChatTurnCallbacks,
111
106
  ): Promise<void> {
112
107
  // Log and append user message
113
- await logInteraction(session.conn, session.threadId, {
114
- role: "user",
115
- kind: "message",
116
- content: userMessage,
117
- });
108
+ await withDb(session.dbPath, (conn) =>
109
+ logInteraction(conn, session.threadId, {
110
+ role: "user",
111
+ kind: "message",
112
+ content: userMessage,
113
+ }),
114
+ );
118
115
 
119
116
  session.messages.push({ role: "user", content: userMessage });
120
117
 
@@ -122,7 +119,7 @@ export async function sendMessage(
122
119
  if (session.messages.length === 1) {
123
120
  void generateThreadTitle(
124
121
  session.config,
125
- session.conn,
122
+ session.dbPath,
126
123
  session.threadId,
127
124
  userMessage,
128
125
  );
@@ -132,15 +129,14 @@ export async function sendMessage(
132
129
  messages: session.messages,
133
130
  projectDir: session.projectDir,
134
131
  config: session.config,
135
- conn: session.conn,
132
+ dbPath: session.dbPath,
136
133
  threadId: session.threadId,
137
- toolCtx: session.toolCtx,
134
+ mcpxClient: session.mcpxClient,
138
135
  callbacks,
139
136
  });
140
137
  }
141
138
 
142
139
  export async function endChatSession(session: ChatSession): Promise<void> {
143
- await endThread(session.conn, session.threadId);
140
+ await withDb(session.dbPath, (conn) => endThread(conn, session.threadId));
144
141
  await session.cleanup();
145
- session.conn.close();
146
142
  }
@@ -109,7 +109,9 @@ export function registerDaemonCommand(program: Command) {
109
109
  daemon
110
110
  .command("list")
111
111
  .description("List all registered Botholomew projects on this machine")
112
- .action(async () => {
112
+ .option("-l, --limit <n>", "max number of projects", Number.parseInt)
113
+ .option("-o, --offset <n>", "skip first N projects", Number.parseInt)
114
+ .action(async (opts: { limit?: number; offset?: number }) => {
113
115
  const { listAllWatchdogProjects } = await import("../daemon/watchdog.ts");
114
116
  try {
115
117
  const projects = await listAllWatchdogProjects();
@@ -117,10 +119,21 @@ export function registerDaemonCommand(program: Command) {
117
119
  logger.dim("No registered projects found.");
118
120
  return;
119
121
  }
120
- for (const p of projects) {
122
+ const total = projects.length;
123
+ const start = opts.offset ?? 0;
124
+ const end = opts.limit ? start + opts.limit : undefined;
125
+ const page = projects.slice(start, end);
126
+ if (page.length === 0) {
127
+ logger.dim(`No projects on this page (total: ${total}).`);
128
+ return;
129
+ }
130
+ for (const p of page) {
121
131
  logger.info(p.projectDir);
122
132
  logger.dim(` Config: ${p.configPath}`);
123
133
  }
134
+ if (page.length !== total) {
135
+ logger.dim(`\nshowing ${page.length} of ${total} project(s)`);
136
+ }
124
137
  } catch (err) {
125
138
  logger.error(
126
139
  `Failed to list projects: ${err instanceof Error ? err.message : err}`,
@@ -19,9 +19,18 @@ export function registerScheduleCommand(program: Command) {
19
19
  .description("List all schedules")
20
20
  .option("--enabled", "show only enabled schedules")
21
21
  .option("--disabled", "show only disabled schedules")
22
+ .option("-l, --limit <n>", "max number of schedules", Number.parseInt)
23
+ .option("-o, --offset <n>", "skip first N schedules", Number.parseInt)
22
24
  .action((opts) =>
23
25
  withDb(program, async (conn) => {
24
- const filters: { enabled?: boolean } = {};
26
+ const filters: {
27
+ enabled?: boolean;
28
+ limit?: number;
29
+ offset?: number;
30
+ } = {
31
+ limit: opts.limit,
32
+ offset: opts.offset,
33
+ };
25
34
  if (opts.enabled) filters.enabled = true;
26
35
  if (opts.disabled) filters.enabled = false;
27
36
 
@@ -27,7 +27,9 @@ export function registerSkillCommand(program: Command) {
27
27
  skill
28
28
  .command("list")
29
29
  .description("List all skills loaded from .botholomew/skills/")
30
- .action(async () => {
30
+ .option("-l, --limit <n>", "max number of skills", Number.parseInt)
31
+ .option("-o, --offset <n>", "skip first N skills", Number.parseInt)
32
+ .action(async (opts: { limit?: number; offset?: number }) => {
31
33
  const dir = program.opts().dir;
32
34
  const skills = await loadSkills(dir);
33
35
 
@@ -39,12 +41,21 @@ export function registerSkillCommand(program: Command) {
39
41
  const sorted = [...skills.values()].sort((a, b) =>
40
42
  a.name.localeCompare(b.name),
41
43
  );
44
+ const total = sorted.length;
45
+ const start = opts.offset ?? 0;
46
+ const end = opts.limit ? start + opts.limit : undefined;
47
+ const page = sorted.slice(start, end);
48
+
49
+ if (page.length === 0) {
50
+ logger.dim(`No skills on this page (total: ${total}).`);
51
+ return;
52
+ }
42
53
 
43
54
  const header = `${ansis.bold("Name".padEnd(20))} ${ansis.bold("Description".padEnd(40))} ${ansis.bold("Args".padEnd(20))} ${ansis.bold("Path")}`;
44
55
  console.log(header);
45
56
  console.log("-".repeat(header.length));
46
57
 
47
- for (const s of sorted) {
58
+ for (const s of page) {
48
59
  const name = s.name.padEnd(20);
49
60
  const desc = s.description
50
61
  ? s.description.slice(0, 39).padEnd(40)
@@ -61,7 +72,11 @@ export function registerSkillCommand(program: Command) {
61
72
  console.log(`${name} ${desc} ${args} ${path}`);
62
73
  }
63
74
 
64
- console.log(`\n${ansis.dim(`${sorted.length} skill(s)`)}`);
75
+ const footer =
76
+ page.length === total
77
+ ? `${total} skill(s)`
78
+ : `showing ${page.length} of ${total} skill(s)`;
79
+ console.log(`\n${ansis.dim(footer)}`);
65
80
  });
66
81
 
67
82
  skill
@@ -17,16 +17,18 @@ export function registerTaskCommand(program: Command) {
17
17
 
18
18
  task
19
19
  .command("list")
20
- .description("List all tasks")
20
+ .description("List all tasks (newest first)")
21
21
  .option("-s, --status <status>", "filter by status")
22
22
  .option("-p, --priority <priority>", "filter by priority")
23
- .option("-l, --limit <n>", "max number of tasks", parseInt)
23
+ .option("-l, --limit <n>", "max number of tasks", Number.parseInt)
24
+ .option("-o, --offset <n>", "skip first N tasks", Number.parseInt)
24
25
  .action((opts) =>
25
26
  withDb(program, async (conn) => {
26
27
  const tasks = await listTasks(conn, {
27
28
  status: opts.status,
28
29
  priority: opts.priority,
29
30
  limit: opts.limit,
31
+ offset: opts.offset,
30
32
  });
31
33
 
32
34
  if (tasks.length === 0) {
@@ -34,9 +36,15 @@ export function registerTaskCommand(program: Command) {
34
36
  return;
35
37
  }
36
38
 
39
+ const header = `${ansis.bold("ID".padEnd(36))} ${ansis.bold("Status".padEnd(11))} ${ansis.bold("Priority".padEnd(6))} ${ansis.bold("Created".padEnd(19))} ${ansis.bold("Updated".padEnd(19))} ${ansis.bold("Name")}`;
40
+ console.log(header);
41
+ console.log("-".repeat(120));
42
+
37
43
  for (const t of tasks) {
38
44
  printTask(t);
39
45
  }
46
+
47
+ console.log(`\n${ansis.dim(`${tasks.length} task(s)`)}`);
40
48
  }),
41
49
  );
42
50
 
@@ -154,10 +162,26 @@ function priorityColor(priority: Task["priority"]): string {
154
162
  }
155
163
  }
156
164
 
165
+ function formatTime(date: Date): string {
166
+ return date
167
+ .toISOString()
168
+ .replace("T", " ")
169
+ .replace(/\.\d{3}Z$/, "");
170
+ }
171
+
172
+ function padColored(colored: string, raw: string, width: number): string {
173
+ const padding = Math.max(0, width - raw.length);
174
+ return colored + " ".repeat(padding);
175
+ }
176
+
157
177
  function printTask(t: Task) {
158
- const id = ansis.dim(t.id);
178
+ const id = ansis.dim(t.id.padEnd(36));
179
+ const status = padColored(statusColor(t.status), t.status, 11);
180
+ const priority = padColored(priorityColor(t.priority), t.priority, 6);
181
+ const created = ansis.dim(formatTime(t.created_at).padEnd(19));
182
+ const updated = ansis.dim(formatTime(t.updated_at).padEnd(19));
159
183
  console.log(
160
- ` ${id} ${statusColor(t.status)} ${priorityColor(t.priority)} ${t.name}`,
184
+ `${id} ${status} ${priority} ${created} ${updated} ${t.name}`,
161
185
  );
162
186
  }
163
187
 
@@ -19,12 +19,14 @@ export function registerThreadCommand(program: Command) {
19
19
  .command("list")
20
20
  .description("List threads")
21
21
  .option("-t, --type <type>", "filter by type (daemon_tick, chat_session)")
22
- .option("-l, --limit <n>", "max number of threads", parseInt)
22
+ .option("-l, --limit <n>", "max number of threads", Number.parseInt)
23
+ .option("-o, --offset <n>", "skip first N threads", Number.parseInt)
23
24
  .action((opts) =>
24
25
  withDb(program, async (conn) => {
25
26
  const threads = await listThreads(conn, {
26
27
  type: opts.type,
27
28
  limit: opts.limit,
29
+ offset: opts.offset,
28
30
  });
29
31
 
30
32
  if (threads.length === 0) {
@@ -2,6 +2,7 @@ import ansis from "ansis";
2
2
  import type { Command } from "commander";
3
3
  import { z } from "zod";
4
4
  import { loadConfig } from "../config/loader.ts";
5
+ import { getDbPath } from "../constants.ts";
5
6
  import { registerAllTools } from "../tools/registry.ts";
6
7
  import {
7
8
  type AnyToolDefinition,
@@ -112,6 +113,7 @@ function registerToolAsCLI(parent: Command, tool: AnyToolDefinition) {
112
113
 
113
114
  const ctx: ToolContext = {
114
115
  conn,
116
+ dbPath: getDbPath(dir),
115
117
  projectDir: dir,
116
118
  config: await loadConfig(dir),
117
119
  mcpxClient: null,
@@ -1,23 +1,22 @@
1
1
  import type { Command } from "commander";
2
2
  import { getDbPath } from "../constants.ts";
3
3
  import type { DbConnection } from "../db/connection.ts";
4
- import { getConnection } from "../db/connection.ts";
4
+ import { withDb as coreWithDb } from "../db/connection.ts";
5
5
  import { migrate } from "../db/schema.ts";
6
6
 
7
7
  /**
8
8
  * Open a migrated DB connection from the CLI --dir flag, run the callback,
9
- * and guarantee the connection is closed afterward.
9
+ * and guarantee the connection is closed afterward. Retries on lock
10
+ * conflicts so CLI invocations cooperate with a running daemon/chat.
10
11
  */
11
12
  export async function withDb<T>(
12
13
  program: Command,
13
14
  fn: (conn: DbConnection, dir: string) => Promise<T>,
14
15
  ): Promise<T> {
15
16
  const dir = program.opts().dir;
16
- const conn = await getConnection(getDbPath(dir));
17
- await migrate(conn);
18
- try {
19
- return await fn(conn, dir);
20
- } finally {
21
- conn.close();
22
- }
17
+ const dbPath = getDbPath(dir);
18
+ return coreWithDb(dbPath, async (conn) => {
19
+ await migrate(conn);
20
+ return fn(conn, dir);
21
+ });
23
22
  }
@@ -161,6 +161,7 @@ async function runFetcherLoop(
161
161
 
162
162
  const toolCtx: ToolContext = {
163
163
  conn: null as unknown as DbConnection,
164
+ dbPath: "",
164
165
  projectDir: "",
165
166
  config,
166
167
  mcpxClient,
@@ -79,7 +79,9 @@ export interface IngestionResult {
79
79
 
80
80
  /**
81
81
  * Store a prepared ingestion into the database.
82
- * This is the fast DB-write phase and must be called sequentially.
82
+ * All statements in BEGIN/COMMIT/ROLLBACK must share one connection, so the
83
+ * caller must pass a connection that lives long enough for the transaction
84
+ * (the tool executor wraps each tool call in `withDb`, which satisfies this).
83
85
  */
84
86
  export async function storeIngestion(
85
87
  conn: DbConnection,
@@ -1,7 +1,7 @@
1
1
  import ansis from "ansis";
2
2
  import { loadConfig } from "../config/loader.ts";
3
3
  import { getDbPath } from "../constants.ts";
4
- import { getConnection } from "../db/connection.ts";
4
+ import { withDb } from "../db/connection.ts";
5
5
  import { migrate } from "../db/schema.ts";
6
6
  import { createMcpxClient } from "../mcpx/client.ts";
7
7
  import { logger } from "../utils/logger.ts";
@@ -49,8 +49,10 @@ export async function startDaemon(
49
49
  ): Promise<void> {
50
50
  const config = await loadConfig(projectDir);
51
51
  const dbPath = getDbPath(projectDir);
52
- const conn = await getConnection(dbPath);
53
- await migrate(conn);
52
+
53
+ // One short-lived connection to apply migrations. After this returns,
54
+ // the file lock is released so CLI invocations can run freely.
55
+ await withDb(dbPath, (conn) => migrate(conn));
54
56
 
55
57
  // Initialize MCPX client for external tool access
56
58
  const mcpxClient = await createMcpxClient(projectDir);
@@ -64,7 +66,6 @@ export async function startDaemon(
64
66
  logger.info("Daemon shutting down...");
65
67
  await mcpxClient?.close();
66
68
  await removePidFile(projectDir);
67
- conn.close();
68
69
  process.exit(0);
69
70
  };
70
71
 
@@ -87,7 +88,7 @@ export async function startDaemon(
87
88
  try {
88
89
  didWork = await tick(
89
90
  projectDir,
90
- conn,
91
+ dbPath,
91
92
  config,
92
93
  mcpxClient,
93
94
  callbacks,