botholomew 0.9.10 → 0.9.12

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
@@ -31,8 +31,9 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
31
31
  DuckDB. Copy it, share it, check it in (or `.gitignore` it).
32
32
  - **Your data, your disk.** Project state — tasks, threads, ingested
33
33
  context, embeddings — lives in `.botholomew/`, indexed in DuckDB with
34
- HNSW for vector search. Model calls go direct to Anthropic and OpenAI;
35
- any further reach is scoped to the MCP servers you add.
34
+ BM25 keyword search and `array_cosine_distance` vector search. Model
35
+ calls go direct to Anthropic and OpenAI; any further reach is scoped to
36
+ the MCP servers you add.
36
37
  - **Extensible.** External tools come from MCP servers via
37
38
  [MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
38
39
  Slack, GitHub) or connect through an MCP gateway like
@@ -115,12 +116,14 @@ my-project/
115
116
  soul.md # always-loaded identity (not agent-editable)
116
117
  beliefs.md # always-loaded, agent-editable priors
117
118
  goals.md # always-loaded, agent-editable goals
119
+ capabilities.md # always-loaded, agent-editable tool inventory
118
120
  config.json # models, tick interval, API keys
119
121
  data.duckdb # tasks, schedules, context, embeddings, logs
120
122
  mcpx/servers.json # external MCP servers (Gmail, Slack, …)
121
- skills/ # user-defined slash commands
123
+ skills/ # slash commands (built-ins + user-defined)
122
124
  summarize.md
123
125
  standup.md
126
+ capabilities.md
124
127
  logs/ # per-worker log files (one file per spawned worker)
125
128
  <worker-id>.log
126
129
  ```
@@ -140,14 +143,14 @@ Everything the agent can touch is here. No surprises.
140
143
  | `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
141
144
  | `botholomew chat` | Interactive Ink/React TUI |
142
145
  | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
143
- | `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
144
- | `botholomew context add\|list\|view\|search\|refresh\|remove` | Ingest & browse knowledge (files, folders, URLs) |
146
+ | `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work |
147
+ | `botholomew context add\|list\|search\|chunks\|refresh\|delete` | Ingest & browse knowledge (files, folders, URLs); also exposes the agent's `read`/`write`/`tree`/`edit`/… tools as subcommands |
145
148
  | `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
146
- | `botholomew mcpx servers\|add\|remove\|info\|search\|exec\|ping\|auth\|import-global` | Configure external MCP servers |
149
+ | `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
147
150
  | `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
148
- | `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
149
151
  | `botholomew thread list\|view` | Browse the agent's interaction history |
150
152
  | `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
153
+ | `botholomew db doctor [--repair]` | Probe each table for primary-key index corruption; rebuild via EXPORT/IMPORT |
151
154
  | `botholomew upgrade` | Self-update |
152
155
 
153
156
  All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
@@ -175,7 +178,7 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
175
178
  │ ┌───────────┐ ┌──────────────┐ │
176
179
  │ │ tasks │ │ context_items│ │
177
180
  │ │ schedules │ │ embeddings │ │
178
- │ │ workers │ │ (HNSW) │ │
181
+ │ │ workers │ │ (FTS+vector)│ │
179
182
  │ │ threads │ │ │ │
180
183
  │ └───────────┘ └──────────────┘ │
181
184
  └─────┬───────────────────────────────┘
@@ -203,8 +206,8 @@ Topics worth understanding in detail:
203
206
  - **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
204
207
  "files" are actually DuckDB rows, and how `context_read`/`context_write` work.
205
208
  - **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
206
- chunking, OpenAI embeddings, and DuckDB's HNSW-accelerated keyword +
207
- vector search.
209
+ chunking, OpenAI embeddings, and DuckDB BM25 + linear-scan vector
210
+ search merged with reciprocal rank fusion.
208
211
  - **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
209
212
  validation, stale-task recovery, and natural-language recurring schedules.
210
213
  - **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
@@ -226,9 +229,9 @@ Topics worth understanding in detail:
226
229
  ## Tech stack
227
230
 
228
231
  - **[Bun](https://bun.sh)** + TypeScript
229
- - **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`, with the
230
- **[VSS extension](https://duckdb.org/docs/stable/extensions/vss)** for
231
- native vector search
232
+ - **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`
233
+ `array_cosine_distance()` (core DuckDB) for vector search, plus the
234
+ built-in FTS extension for BM25 keyword search
232
235
  - **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
233
236
  Claude — the reasoning model
234
237
  - **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
6
6
  import { registerChatCommand } from "./commands/chat.ts";
7
7
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
8
8
  import { registerContextCommand } from "./commands/context.ts";
9
+ import { registerDbCommand } from "./commands/db.ts";
9
10
  import { registerInitCommand } from "./commands/init.ts";
10
11
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
12
  import { registerNukeCommand } from "./commands/nuke.ts";
@@ -40,6 +41,7 @@ registerThreadCommand(program);
40
41
  registerScheduleCommand(program);
41
42
  registerChatCommand(program);
42
43
  registerContextCommand(program);
44
+ registerDbCommand(program);
43
45
  registerCapabilitiesCommand(program);
44
46
  registerMcpxCommand(program);
45
47
  registerSkillCommand(program);
@@ -0,0 +1,123 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import { getDbPath } from "../constants.ts";
4
+ import { withDb as coreWithDb } from "../db/connection.ts";
5
+ import {
6
+ isPidAlive,
7
+ type ProbeResult,
8
+ probeAllTables,
9
+ repairDatabase,
10
+ } from "../db/doctor.ts";
11
+ import { listWorkers, type Worker } from "../db/workers.ts";
12
+ import { logger } from "../utils/logger.ts";
13
+
14
+ function statusBadge(status: ProbeResult["status"]): string {
15
+ switch (status) {
16
+ case "ok":
17
+ return ansis.green("ok");
18
+ case "empty":
19
+ return ansis.dim("empty");
20
+ case "missing":
21
+ return ansis.dim("missing");
22
+ case "corrupt":
23
+ return ansis.red.bold("corrupt");
24
+ }
25
+ }
26
+
27
+ function printResults(results: ProbeResult[]) {
28
+ const nameWidth = Math.max(...results.map((r) => r.table.length));
29
+ for (const r of results) {
30
+ const name = r.table.padEnd(nameWidth + 2);
31
+ const detail = r.message ? ansis.dim(` ${r.message.slice(0, 200)}`) : "";
32
+ console.log(` ${name}${statusBadge(r.status)}${detail}`);
33
+ }
34
+ }
35
+
36
+ export function registerDbCommand(program: Command) {
37
+ const db = program
38
+ .command("db")
39
+ .description("Inspect and repair the project database");
40
+
41
+ db.command("doctor")
42
+ .description(
43
+ "Probe every table for primary-key index corruption and optionally repair via EXPORT/IMPORT",
44
+ )
45
+ .option(
46
+ "-r, --repair",
47
+ "Rebuild the database file from an export when corruption is detected",
48
+ )
49
+ .action((opts) => doctor(program, opts.repair === true));
50
+ }
51
+
52
+ async function doctor(program: Command, repair: boolean): Promise<void> {
53
+ const dir = program.opts().dir as string;
54
+ const dbPath = getDbPath(dir);
55
+
56
+ logger.info(`Probing tables in ${dbPath}`);
57
+ const results = await probeAllTables(dbPath);
58
+ printResults(results);
59
+
60
+ const corrupt = results.filter((r) => r.status === "corrupt");
61
+ if (corrupt.length === 0) {
62
+ logger.success("No corruption detected.");
63
+ return;
64
+ }
65
+
66
+ logger.error(
67
+ `${corrupt.length} table(s) have corrupted indexes: ${corrupt
68
+ .map((r) => r.table)
69
+ .join(", ")}`,
70
+ );
71
+
72
+ if (!repair) {
73
+ console.log("");
74
+ console.log(
75
+ ansis.yellow(
76
+ "Re-run with --repair to rebuild the database file (creates a timestamped backup).",
77
+ ),
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ // Repair requires exclusive access — refuse if any worker is actually
83
+ // running, otherwise the EXPORT would race with the worker's writes.
84
+ // Stale `status='running'` rows whose PID is dead (the exact case that
85
+ // tends to coexist with workers-table corruption) are reported but do
86
+ // not block repair: trying to flip them to `stopped` would just trip
87
+ // the same corruption we're about to fix.
88
+ const running = await coreWithDb(dbPath, async (conn) => {
89
+ try {
90
+ return await listWorkers(conn, { status: "running" });
91
+ } catch {
92
+ return [] as Worker[];
93
+ }
94
+ });
95
+ const live = running.filter((w) => isPidAlive(w.pid));
96
+ const stale = running.filter((w) => !isPidAlive(w.pid));
97
+ if (live.length > 0) {
98
+ logger.error(
99
+ `${live.length} worker(s) actually running. Stop them first: botholomew worker stop <id>`,
100
+ );
101
+ for (const w of live) {
102
+ logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
103
+ }
104
+ process.exit(1);
105
+ }
106
+ if (stale.length > 0) {
107
+ logger.warn(
108
+ `${stale.length} worker row(s) marked 'running' but PID is dead — proceeding (rows will be carried through repair, then reapable):`,
109
+ );
110
+ for (const w of stale) {
111
+ logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
112
+ }
113
+ }
114
+
115
+ logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
116
+ const result = await repairDatabase(dbPath);
117
+ logger.success(
118
+ `Repaired in ${result.durationMs}ms. Backup: ${result.backupDbPath}`,
119
+ );
120
+ logger.dim(
121
+ " Re-run `botholomew db doctor` to confirm. Delete the backup once you're sure.",
122
+ );
123
+ }
@@ -0,0 +1,242 @@
1
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { withDb } from "./connection.ts";
4
+
5
+ /**
6
+ * Tables we probe for primary-key index integrity. Every user table has a
7
+ * single-column PK that we exercise with a self-update (SET pk = pk WHERE
8
+ * pk = ...). DuckDB still walks the index for the SET, which surfaces
9
+ * "Failed to delete all rows from index" FATAL errors when the index is
10
+ * out of sync with the row data. `_migrations` is excluded — it is small,
11
+ * append-only, and rebuilding it would defeat its purpose.
12
+ */
13
+ export const PROBE_TABLES: ReadonlyArray<{ name: string; pk: string }> = [
14
+ { name: "workers", pk: "id" },
15
+ { name: "tasks", pk: "id" },
16
+ { name: "schedules", pk: "id" },
17
+ { name: "threads", pk: "id" },
18
+ { name: "interactions", pk: "id" },
19
+ { name: "context_items", pk: "id" },
20
+ { name: "embeddings", pk: "id" },
21
+ { name: "daemon_state", pk: "key" },
22
+ ];
23
+
24
+ export type ProbeStatus = "ok" | "empty" | "missing" | "corrupt";
25
+
26
+ export interface ProbeResult {
27
+ table: string;
28
+ status: ProbeStatus;
29
+ /** Detail message when status is corrupt or missing. */
30
+ message?: string;
31
+ }
32
+
33
+ /**
34
+ * Probe a single table for index corruption by spawning a child Bun
35
+ * process. We use a child process because a corrupt PK index in DuckDB
36
+ * surfaces as a Bun panic (a C++ exception that unwinds past the NAPI
37
+ * boundary), which would kill the doctor itself. The child reports its
38
+ * verdict on stdout and exits.
39
+ *
40
+ * Uses absolute import path resolved against this file so the spawned
41
+ * Bun process picks up the same `@duckdb/node-api` install.
42
+ */
43
+ export async function probeTable(
44
+ dbPath: string,
45
+ table: string,
46
+ pk: string,
47
+ ): Promise<ProbeResult> {
48
+ const script = `
49
+ const { DuckDBInstance } = await import("@duckdb/node-api");
50
+ const dbPath = ${JSON.stringify(dbPath)};
51
+ const table = ${JSON.stringify(table)};
52
+ const pk = ${JSON.stringify(pk)};
53
+ let inst;
54
+ try {
55
+ inst = await DuckDBInstance.create(dbPath);
56
+ } catch (e) {
57
+ process.stdout.write("MISSING:" + (e?.message ?? String(e)));
58
+ process.exit(0);
59
+ }
60
+ const c = await inst.connect();
61
+ try {
62
+ const r = await c.runAndReadAll(\`SELECT \${pk} FROM \${table} LIMIT 1\`);
63
+ if (r.getRows().length === 0) {
64
+ process.stdout.write("EMPTY");
65
+ process.exit(0);
66
+ }
67
+ } catch (e) {
68
+ const msg = String(e?.message ?? e);
69
+ // Table doesn't exist yet (e.g., schema older than this doctor) — not
70
+ // a corruption signal, just skip it.
71
+ if (msg.includes("does not exist") || msg.includes("Catalog Error")) {
72
+ process.stdout.write("MISSING:" + msg);
73
+ process.exit(0);
74
+ }
75
+ process.stdout.write("CORRUPT:" + msg);
76
+ process.exit(2);
77
+ }
78
+ try {
79
+ await c.run(\`UPDATE \${table} SET \${pk} = \${pk} WHERE \${pk} = (SELECT \${pk} FROM \${table} LIMIT 1)\`);
80
+ process.stdout.write("OK");
81
+ process.exit(0);
82
+ } catch (e) {
83
+ process.stdout.write("CORRUPT:" + (e?.message ?? String(e)));
84
+ process.exit(2);
85
+ }
86
+ `;
87
+
88
+ // Discard the child's stderr. When the probe panics, Bun writes a multi-
89
+ // line crash banner there which would otherwise spill into our table
90
+ // output via the fallback message. The exit code alone tells us what we
91
+ // need to know.
92
+ const proc = Bun.spawn(["bun", "-e", script], {
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ });
95
+ const [stdout, exitCode] = await Promise.all([
96
+ new Response(proc.stdout).text(),
97
+ proc.exited,
98
+ ]);
99
+
100
+ // Bun panic: process killed by SIGTRAP / non-zero exit with no stdout
101
+ // verdict. Treat any unrecognized exit as corruption — better to flag
102
+ // for repair than to silently miss a problem.
103
+ if (stdout.startsWith("OK")) return { table, status: "ok" };
104
+ if (stdout.startsWith("EMPTY")) return { table, status: "empty" };
105
+ if (stdout.startsWith("MISSING:")) {
106
+ return {
107
+ table,
108
+ status: "missing",
109
+ message: firstLine(stdout.slice("MISSING:".length)),
110
+ };
111
+ }
112
+ if (stdout.startsWith("CORRUPT:")) {
113
+ return {
114
+ table,
115
+ status: "corrupt",
116
+ message: firstLine(stdout.slice("CORRUPT:".length)),
117
+ };
118
+ }
119
+ return {
120
+ table,
121
+ status: "corrupt",
122
+ message: `child exited with code ${exitCode} (likely native panic)`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Run probes for every known table. Sequential rather than parallel so we
128
+ * cooperate with DuckDB's per-process file lock and don't multiply the
129
+ * blast radius of a panic.
130
+ */
131
+ export async function probeAllTables(dbPath: string): Promise<ProbeResult[]> {
132
+ const results: ProbeResult[] = [];
133
+ for (const { name, pk } of PROBE_TABLES) {
134
+ results.push(await probeTable(dbPath, name, pk));
135
+ }
136
+ return results;
137
+ }
138
+
139
+ export interface RepairResult {
140
+ backupDbPath: string;
141
+ exportDir: string;
142
+ durationMs: number;
143
+ }
144
+
145
+ /**
146
+ * Repair `dbPath` by exporting its contents and importing into a fresh
147
+ * file. EXPORT DATABASE reads via sequential scans, not via PK indexes,
148
+ * so it survives the kind of index corruption that breaks UPDATE/DELETE.
149
+ * IMPORT DATABASE rebuilds every index from the data, which restores
150
+ * write integrity.
151
+ *
152
+ * Steps:
153
+ * 1. CHECKPOINT (best-effort) to flush WAL.
154
+ * 2. EXPORT DATABASE to `<dotDir>/.export-<timestamp>`.
155
+ * 3. Move `data.duckdb` (and `.wal`) to `data.duckdb.bak-<timestamp>`.
156
+ * 4. Open a fresh DB at the original path and IMPORT DATABASE.
157
+ * 5. Leave the export dir on disk — cheap insurance if step 4 ever fails
158
+ * mid-way; cleanup on the next successful run.
159
+ *
160
+ * The caller is responsible for ensuring no other process holds the DB
161
+ * (no running workers, no chat session, no TUI).
162
+ */
163
+ export async function repairDatabase(dbPath: string): Promise<RepairResult> {
164
+ const start = Date.now();
165
+ const dotDir = dirname(dbPath);
166
+ await mkdir(dotDir, { recursive: true });
167
+
168
+ const stamp = new Date()
169
+ .toISOString()
170
+ .replace(/[:.]/g, "-")
171
+ .replace(/Z$/, "");
172
+ const exportDir = join(dotDir, `.export-${stamp}`);
173
+ const backupDbPath = `${dbPath}.bak-${stamp}`;
174
+ const walPath = `${dbPath}.wal`;
175
+ const backupWalPath = `${backupDbPath}.wal`;
176
+
177
+ await withDb(dbPath, async (conn) => {
178
+ try {
179
+ await conn.exec("CHECKPOINT");
180
+ } catch {
181
+ // CHECKPOINT can fail on an already-invalidated DB; the EXPORT
182
+ // below is what actually matters.
183
+ }
184
+ await conn.exec(`EXPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
185
+ });
186
+
187
+ await rename(dbPath, backupDbPath);
188
+ if (await pathExists(walPath)) {
189
+ await rename(walPath, backupWalPath);
190
+ }
191
+
192
+ await withDb(dbPath, async (conn) => {
193
+ await conn.exec(`IMPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
194
+ });
195
+
196
+ // Best-effort cleanup of the export dir. Leave it on failure — the user
197
+ // still has data.duckdb (rebuilt) and the backup.
198
+ try {
199
+ await rm(exportDir, { recursive: true, force: true });
200
+ } catch {
201
+ // ignore
202
+ }
203
+
204
+ return {
205
+ backupDbPath,
206
+ exportDir,
207
+ durationMs: Date.now() - start,
208
+ };
209
+ }
210
+
211
+ async function pathExists(p: string): Promise<boolean> {
212
+ try {
213
+ await stat(p);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function firstLine(s: string): string {
221
+ const trimmed = s.trim();
222
+ const nl = trimmed.indexOf("\n");
223
+ return nl === -1 ? trimmed : trimmed.slice(0, nl);
224
+ }
225
+
226
+ /**
227
+ * Send signal 0 to test whether `pid` corresponds to a live process. Returns
228
+ * false on ESRCH (no such process) and on any other error (including EPERM,
229
+ * which we conservatively treat as "not ours, not relevant"). Used by the
230
+ * doctor's safety gate to distinguish workers actually running from rows
231
+ * that say `status = 'running'` because the worker crashed before flipping
232
+ * its row to `stopped` or `dead`.
233
+ */
234
+ export function isPidAlive(pid: number): boolean {
235
+ if (!pid || pid < 1) return false;
236
+ try {
237
+ process.kill(pid, 0);
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
package/src/worker/llm.ts CHANGED
@@ -11,12 +11,17 @@ import { getTask, type Task } from "../db/tasks.ts";
11
11
  import { logInteraction } from "../db/threads.ts";
12
12
  import { registerAllTools } from "../tools/registry.ts";
13
13
  import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
14
+ import { logger } from "../utils/logger.ts";
14
15
  import { fitToContextWindow, getMaxInputTokens } from "./context.ts";
15
16
  import { clearLargeResults, maybeStoreResult } from "./large-results.ts";
16
17
  import { createLlmClient } from "./llm-client.ts";
17
18
 
18
19
  registerAllTools();
19
20
 
21
+ function truncate(s: string, max: number): string {
22
+ return s.length > max ? `${s.slice(0, max)}…` : s;
23
+ }
24
+
20
25
  export interface WorkerStreamCallbacks {
21
26
  onToken: (text: string) => void;
22
27
  onToolStart: (name: string, input: string) => void;
@@ -153,6 +158,9 @@ export async function runAgentLoop(input: {
153
158
  tokenCount,
154
159
  }),
155
160
  );
161
+ if (!callbacks) {
162
+ logger.phase("assistant", block.text);
163
+ }
156
164
  }
157
165
  }
158
166
 
@@ -175,6 +183,12 @@ export async function runAgentLoop(input: {
175
183
  for (const toolUse of toolUseBlocks) {
176
184
  const toolInput = JSON.stringify(toolUse.input);
177
185
  callbacks?.onToolStart(toolUse.name, toolInput);
186
+ if (!callbacks) {
187
+ logger.phase(
188
+ "tool-call",
189
+ `${toolUse.name} ${truncate(toolInput, 200)}`,
190
+ );
191
+ }
178
192
  await withDb(dbPath, (conn) =>
179
193
  logInteraction(conn, threadId, {
180
194
  role: "assistant",
@@ -222,6 +236,11 @@ export async function runAgentLoop(input: {
222
236
  durationMs,
223
237
  }),
224
238
  );
239
+ if (!callbacks) {
240
+ const seconds = (durationMs / 1000).toFixed(1);
241
+ const status = result.isError ? "err" : "ok";
242
+ logger.phase("tool-result", `${toolUse.name} ${status} in ${seconds}s`);
243
+ }
225
244
 
226
245
  if (result.terminal && result.agentResult) {
227
246
  return result.agentResult;
@@ -133,6 +133,9 @@ async function runClaimedTask(opts: {
133
133
  const { projectDir, dbPath, config, mcpxClient, callbacks, task } = opts;
134
134
 
135
135
  logger.info(`Claimed task: ${task.name} (${task.id})`);
136
+ if (!callbacks && task.description) {
137
+ logger.dim(task.description);
138
+ }
136
139
  callbacks?.onTaskStart(task);
137
140
 
138
141
  const threadId = await withDb(dbPath, (conn) =>