botholomew 0.12.5 → 0.13.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 (103) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/utils/title.ts +5 -7
  80. package/src/utils/v7-date.ts +47 -0
  81. package/src/worker/heartbeat.ts +46 -24
  82. package/src/worker/index.ts +13 -15
  83. package/src/worker/llm.ts +30 -37
  84. package/src/worker/prompt.ts +19 -41
  85. package/src/worker/schedules.ts +48 -69
  86. package/src/worker/spawn.ts +11 -11
  87. package/src/worker/tick.ts +39 -43
  88. package/src/workers/store.ts +247 -0
  89. package/src/commands/tools.ts +0 -367
  90. package/src/context/describer.ts +0 -140
  91. package/src/context/drives.ts +0 -110
  92. package/src/context/ingest.ts +0 -162
  93. package/src/context/refresh.ts +0 -183
  94. package/src/db/context.ts +0 -637
  95. package/src/db/daemon-state.ts +0 -6
  96. package/src/db/reembed.ts +0 -113
  97. package/src/db/schedules.ts +0 -213
  98. package/src/db/tasks.ts +0 -347
  99. package/src/db/threads.ts +0 -276
  100. package/src/db/workers.ts +0 -212
  101. package/src/tools/context/list-drives.ts +0 -36
  102. package/src/tools/context/refresh.ts +0 -165
  103. package/src/tools/context/search.ts +0 -54
@@ -1,16 +1,15 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
- import { withDb } from "../../db/connection.ts";
3
+ import type { Schedule } from "../../schedules/schema.ts";
4
4
  import {
5
5
  deleteSchedule,
6
6
  listSchedules,
7
- type Schedule,
8
7
  updateSchedule,
9
- } from "../../db/schedules.ts";
8
+ } from "../../schedules/store.ts";
10
9
  import { ansi, theme } from "../theme.ts";
11
10
 
12
11
  interface SchedulePanelProps {
13
- dbPath: string;
12
+ projectDir: string;
14
13
  isActive: boolean;
15
14
  }
16
15
 
@@ -39,6 +38,18 @@ const ENABLED_LABELS: Record<string, string> = {
39
38
  false: "disabled",
40
39
  };
41
40
 
41
+ function formatTimestamp(iso: string | null): string {
42
+ if (!iso) return "(never)";
43
+ const d = new Date(iso);
44
+ if (Number.isNaN(d.getTime())) return iso;
45
+ return d.toLocaleString([], {
46
+ month: "short",
47
+ day: "numeric",
48
+ hour: "2-digit",
49
+ minute: "2-digit",
50
+ });
51
+ }
52
+
42
53
  function buildScheduleDetailAnsi(schedule: Schedule): string {
43
54
  const lines: string[] = [];
44
55
 
@@ -55,35 +66,15 @@ function buildScheduleDetailAnsi(schedule: Schedule): string {
55
66
  `${ansi.bold}${ansi.primary}Frequency${ansi.reset} ${ansi.accent}${schedule.frequency}${ansi.reset}`,
56
67
  );
57
68
 
58
- const created = schedule.created_at.toLocaleString([], {
59
- month: "short",
60
- day: "numeric",
61
- hour: "2-digit",
62
- minute: "2-digit",
63
- });
64
- const updated = schedule.updated_at.toLocaleString([], {
65
- month: "short",
66
- day: "numeric",
67
- hour: "2-digit",
68
- minute: "2-digit",
69
- });
70
69
  lines.push(
71
- `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${created}${ansi.reset}`,
70
+ `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.created_at)}${ansi.reset}`,
72
71
  );
73
72
  lines.push(
74
- `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${updated}${ansi.reset}`,
73
+ `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(schedule.updated_at)}${ansi.reset}`,
75
74
  );
76
75
 
77
- const lastRunDisplay = schedule.last_run_at
78
- ? schedule.last_run_at.toLocaleString([], {
79
- month: "short",
80
- day: "numeric",
81
- hour: "2-digit",
82
- minute: "2-digit",
83
- })
84
- : "(never)";
85
76
  lines.push(
86
- `${ansi.bold}${ansi.primary}Last Run${ansi.reset} ${lastRunDisplay}`,
77
+ `${ansi.bold}${ansi.primary}Last Run${ansi.reset} ${formatTimestamp(schedule.last_run_at)}`,
87
78
  );
88
79
  lines.push("");
89
80
 
@@ -108,7 +99,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
108
99
  }
109
100
 
110
101
  export const SchedulePanel = memo(function SchedulePanel({
111
- dbPath,
102
+ projectDir,
112
103
  isActive,
113
104
  }: SchedulePanelProps) {
114
105
  const { stdout } = useStdout();
@@ -127,14 +118,16 @@ export const SchedulePanel = memo(function SchedulePanel({
127
118
  const refresh = async () => {
128
119
  const filters: { enabled?: boolean } = {};
129
120
  if (enabledFilter !== null) filters.enabled = enabledFilter;
130
- const result = await withDb(dbPath, (conn) =>
131
- listSchedules(conn, filters),
132
- );
133
- if (mounted) {
134
- setSchedules(result);
135
- setSelectedIndex((prev) =>
136
- Math.min(prev, Math.max(0, result.length - 1)),
137
- );
121
+ try {
122
+ const result = await listSchedules(projectDir, filters);
123
+ if (mounted) {
124
+ setSchedules(result);
125
+ setSelectedIndex((prev) =>
126
+ Math.min(prev, Math.max(0, result.length - 1)),
127
+ );
128
+ }
129
+ } catch {
130
+ // ignore — next tick retries
138
131
  }
139
132
  };
140
133
 
@@ -144,7 +137,7 @@ export const SchedulePanel = memo(function SchedulePanel({
144
137
  mounted = false;
145
138
  clearInterval(interval);
146
139
  };
147
- }, [dbPath, enabledFilter, refreshTick]);
140
+ }, [projectDir, enabledFilter, refreshTick]);
148
141
 
149
142
  const selectedSchedule = schedules[selectedIndex];
150
143
 
@@ -180,13 +173,10 @@ export const SchedulePanel = memo(function SchedulePanel({
180
173
 
181
174
  useInput(
182
175
  (input, key) => {
183
- // Delete confirmation mode
184
176
  if (confirmDelete) {
185
177
  if (input === "y" || input === "d") {
186
178
  if (selectedSchedule) {
187
- withDb(dbPath, (conn) =>
188
- deleteSchedule(conn, selectedSchedule.id),
189
- ).then(() => {
179
+ deleteSchedule(projectDir, selectedSchedule.id).then(() => {
190
180
  forceRefresh();
191
181
  });
192
182
  }
@@ -246,11 +236,9 @@ export const SchedulePanel = memo(function SchedulePanel({
246
236
  return;
247
237
  }
248
238
  if (input === "e" && selectedSchedule) {
249
- withDb(dbPath, (conn) =>
250
- updateSchedule(conn, selectedSchedule.id, {
251
- enabled: !selectedSchedule.enabled,
252
- }),
253
- ).then(() => {
239
+ updateSchedule(projectDir, selectedSchedule.id, {
240
+ enabled: !selectedSchedule.enabled,
241
+ }).then(() => {
254
242
  forceRefresh();
255
243
  });
256
244
  return;
@@ -299,7 +287,6 @@ export const SchedulePanel = memo(function SchedulePanel({
299
287
 
300
288
  return (
301
289
  <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
302
- {/* Left sidebar: schedule list */}
303
290
  <Box
304
291
  flexDirection="column"
305
292
  width={SIDEBAR_WIDTH}
@@ -361,7 +348,6 @@ export const SchedulePanel = memo(function SchedulePanel({
361
348
  })}
362
349
  </Box>
363
350
 
364
- {/* Right detail pane */}
365
351
  <Box
366
352
  flexDirection="column"
367
353
  flexGrow={1}
@@ -1,13 +1,16 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
- import { withDb } from "../../db/connection.ts";
4
- import { listTasks } from "../../db/tasks.ts";
5
- import { listWorkers } from "../../db/workers.ts";
3
+ import { listTasks } from "../../tasks/store.ts";
4
+ import { listWorkers } from "../../workers/store.ts";
6
5
  import { LogoChar } from "./Logo.tsx";
7
6
 
8
7
  interface StatusBarProps {
9
8
  projectDir: string;
10
- dbPath: string;
9
+ /**
10
+ * Retained for callers that pass it; unused now that workers + tasks
11
+ * both live on disk. Drop on the next TUI cleanup pass.
12
+ */
13
+ dbPath?: string;
11
14
  chatTitle?: string;
12
15
  onWorkerStatusChange?: (running: boolean) => void;
13
16
  }
@@ -19,8 +22,8 @@ interface Status {
19
22
  }
20
23
 
21
24
  export function StatusBar({
22
- projectDir: _projectDir,
23
- dbPath,
25
+ projectDir,
26
+ dbPath: _dbPath,
24
27
  chatTitle,
25
28
  onWorkerStatusChange,
26
29
  }: StatusBarProps) {
@@ -39,14 +42,11 @@ export function StatusBar({
39
42
  // because logger writes to stdout and would corrupt the Ink render.
40
43
  const refresh = async () => {
41
44
  try {
42
- const [pending, inProgress, workers] = await withDb(
43
- dbPath,
44
- async (conn) => [
45
- await listTasks(conn, { status: "pending" }),
46
- await listTasks(conn, { status: "in_progress" }),
47
- await listWorkers(conn, { status: "running" }),
48
- ],
49
- );
45
+ const [pending, inProgress, workers] = await Promise.all([
46
+ listTasks(projectDir, { status: "pending" }),
47
+ listTasks(projectDir, { status: "in_progress" }),
48
+ listWorkers(projectDir, { status: "running" }),
49
+ ]);
50
50
  if (mounted) {
51
51
  setStatus({
52
52
  workerCount: workers.length,
@@ -66,7 +66,7 @@ export function StatusBar({
66
66
  mounted = false;
67
67
  clearInterval(interval);
68
68
  };
69
- }, [dbPath, onWorkerStatusChange]);
69
+ }, [projectDir, onWorkerStatusChange]);
70
70
 
71
71
  return (
72
72
  <Box paddingX={0}>
@@ -1,17 +1,15 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
- import { withDb } from "../../db/connection.ts";
4
3
  import {
5
- deleteTask,
6
- listTasks,
7
4
  TASK_PRIORITIES,
8
5
  TASK_STATUSES,
9
6
  type Task,
10
- } from "../../db/tasks.ts";
7
+ } from "../../tasks/schema.ts";
8
+ import { deleteTask, listTasks } from "../../tasks/store.ts";
11
9
  import { ansi, theme } from "../theme.ts";
12
10
 
13
11
  interface TaskPanelProps {
14
- dbPath: string;
12
+ projectDir: string;
15
13
  isActive: boolean;
16
14
  }
17
15
 
@@ -60,6 +58,17 @@ const PRIORITY_ANSI: Record<Task["priority"], string> = {
60
58
  low: ansi.muted,
61
59
  };
62
60
 
61
+ function formatTimestamp(iso: string): string {
62
+ const d = new Date(iso);
63
+ if (Number.isNaN(d.getTime())) return iso;
64
+ return d.toLocaleString([], {
65
+ month: "short",
66
+ day: "numeric",
67
+ hour: "2-digit",
68
+ minute: "2-digit",
69
+ });
70
+ }
71
+
63
72
  function buildTaskDetailAnsi(task: Task): string {
64
73
  const lines: string[] = [];
65
74
 
@@ -80,23 +89,11 @@ function buildTaskDetailAnsi(task: Task): string {
80
89
  `${ansi.bold}${ansi.primary}Claimed${ansi.reset} ${task.claimed_by ? task.claimed_by : `${ansi.dim}(unclaimed)${ansi.reset}`}`,
81
90
  );
82
91
 
83
- const created = task.created_at.toLocaleString([], {
84
- month: "short",
85
- day: "numeric",
86
- hour: "2-digit",
87
- minute: "2-digit",
88
- });
89
- const updated = task.updated_at.toLocaleString([], {
90
- month: "short",
91
- day: "numeric",
92
- hour: "2-digit",
93
- minute: "2-digit",
94
- });
95
92
  lines.push(
96
- `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${created}${ansi.reset}`,
93
+ `${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(task.created_at)}${ansi.reset}`,
97
94
  );
98
95
  lines.push(
99
- `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${updated}${ansi.reset}`,
96
+ `${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(task.updated_at)}${ansi.reset}`,
100
97
  );
101
98
  lines.push("");
102
99
 
@@ -126,17 +123,16 @@ function buildTaskDetailAnsi(task: Task): string {
126
123
  lines.push("");
127
124
  }
128
125
 
129
- if (task.context_ids.length > 0) {
130
- lines.push(`${ansi.bold}${ansi.primary}Context IDs${ansi.reset}`);
131
- for (const id of task.context_ids) {
132
- lines.push(` ${ansi.dim}• ${id}${ansi.reset}`);
126
+ if (task.context_paths.length > 0) {
127
+ lines.push(`${ansi.bold}${ansi.primary}Context Paths${ansi.reset}`);
128
+ for (const p of task.context_paths) {
129
+ lines.push(` ${ansi.dim}• ${p}${ansi.reset}`);
133
130
  }
134
131
  }
135
132
 
136
133
  return lines.join("\n");
137
134
  }
138
135
 
139
- // Cycle through filter options: null -> ...values -> null
140
136
  function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
141
137
  if (current === null) return values[0] ?? null;
142
138
  const idx = values.indexOf(current);
@@ -145,7 +141,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
145
141
  }
146
142
 
147
143
  export const TaskPanel = memo(function TaskPanel({
148
- dbPath,
144
+ projectDir,
149
145
  isActive,
150
146
  }: TaskPanelProps) {
151
147
  const { stdout } = useStdout();
@@ -159,7 +155,6 @@ export const TaskPanel = memo(function TaskPanel({
159
155
  );
160
156
  const [refreshTick, setRefreshTick] = useState(0);
161
157
 
162
- // Fetch tasks on mount, filter change, and every 5 seconds
163
158
  // biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
164
159
  useEffect(() => {
165
160
  let mounted = true;
@@ -171,12 +166,16 @@ export const TaskPanel = memo(function TaskPanel({
171
166
  } = {};
172
167
  if (statusFilter) filters.status = statusFilter;
173
168
  if (priorityFilter) filters.priority = priorityFilter;
174
- const result = await withDb(dbPath, (conn) => listTasks(conn, filters));
175
- if (mounted) {
176
- setTasks(result);
177
- setSelectedIndex((prev) =>
178
- Math.min(prev, Math.max(0, result.length - 1)),
179
- );
169
+ try {
170
+ const result = await listTasks(projectDir, filters);
171
+ if (mounted) {
172
+ setTasks(result);
173
+ setSelectedIndex((prev) =>
174
+ Math.min(prev, Math.max(0, result.length - 1)),
175
+ );
176
+ }
177
+ } catch {
178
+ // ignore — next tick retries
180
179
  }
181
180
  };
182
181
 
@@ -186,7 +185,7 @@ export const TaskPanel = memo(function TaskPanel({
186
185
  mounted = false;
187
186
  clearInterval(interval);
188
187
  };
189
- }, [dbPath, statusFilter, priorityFilter, refreshTick]);
188
+ }, [projectDir, statusFilter, priorityFilter, refreshTick]);
190
189
 
191
190
  const selectedTask = tasks[selectedIndex];
192
191
 
@@ -210,7 +209,6 @@ export const TaskPanel = memo(function TaskPanel({
210
209
  ),
211
210
  );
212
211
 
213
- // Reset detail scroll when selection changes
214
212
  // biome-ignore lint/correctness/useExhaustiveDependencies: selectedIndex is the intentional trigger
215
213
  useEffect(() => {
216
214
  setDetailScroll(0);
@@ -275,7 +273,7 @@ export const TaskPanel = memo(function TaskPanel({
275
273
  return;
276
274
  }
277
275
  if (input === "d" && selectedTask) {
278
- withDb(dbPath, (conn) => deleteTask(conn, selectedTask.id)).then(() => {
276
+ deleteTask(projectDir, selectedTask.id).then(() => {
279
277
  forceRefresh();
280
278
  });
281
279
  return;
@@ -323,7 +321,6 @@ export const TaskPanel = memo(function TaskPanel({
323
321
 
324
322
  return (
325
323
  <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
326
- {/* Left sidebar: task list */}
327
324
  <Box
328
325
  flexDirection="column"
329
326
  width={SIDEBAR_WIDTH}
@@ -350,7 +347,7 @@ export const TaskPanel = memo(function TaskPanel({
350
347
  const isSelected = i === selectedIndex;
351
348
  const icon = STATUS_ICONS[task.status];
352
349
  const priorityLabel = PRIORITY_LABELS[task.priority];
353
- const maxName = SIDEBAR_WIDTH - 11; // icon + priority + padding
350
+ const maxName = SIDEBAR_WIDTH - 11;
354
351
  const nameDisplay =
355
352
  task.name.length > maxName
356
353
  ? `${task.name.slice(0, maxName - 1)}…`
@@ -381,7 +378,6 @@ export const TaskPanel = memo(function TaskPanel({
381
378
  })}
382
379
  </Box>
383
380
 
384
- {/* Right detail pane */}
385
381
  <Box
386
382
  flexDirection="column"
387
383
  flexGrow={1}
@@ -1,6 +1,5 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { withDb } from "../../db/connection.ts";
4
3
  import {
5
4
  deleteThread,
6
5
  getInteractionsAfter,
@@ -9,11 +8,11 @@ import {
9
8
  isThreadEnded,
10
9
  listThreads,
11
10
  type Thread,
12
- } from "../../db/threads.ts";
11
+ } from "../../threads/store.ts";
13
12
  import { ansi, theme } from "../theme.ts";
14
13
 
15
14
  interface ThreadPanelProps {
16
- dbPath: string;
15
+ projectDir: string;
17
16
  activeThreadId: string;
18
17
  isActive: boolean;
19
18
  }
@@ -181,7 +180,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
181
180
  }
182
181
 
183
182
  export const ThreadPanel = memo(function ThreadPanel({
184
- dbPath,
183
+ projectDir,
185
184
  activeThreadId,
186
185
  isActive,
187
186
  }: ThreadPanelProps) {
@@ -210,12 +209,16 @@ export const ThreadPanel = memo(function ThreadPanel({
210
209
  const refresh = async () => {
211
210
  const filters: { type?: Thread["type"] } = {};
212
211
  if (typeFilter) filters.type = typeFilter;
213
- const result = await withDb(dbPath, (conn) => listThreads(conn, filters));
214
- if (mounted) {
215
- setThreads(result);
216
- setSelectedIndex((prev) =>
217
- Math.min(prev, Math.max(0, result.length - 1)),
218
- );
212
+ try {
213
+ const result = await listThreads(projectDir, filters);
214
+ if (mounted) {
215
+ setThreads(result);
216
+ setSelectedIndex((prev) =>
217
+ Math.min(prev, Math.max(0, result.length - 1)),
218
+ );
219
+ }
220
+ } catch {
221
+ // ignore — next tick retries
219
222
  }
220
223
  };
221
224
 
@@ -225,7 +228,7 @@ export const ThreadPanel = memo(function ThreadPanel({
225
228
  mounted = false;
226
229
  clearInterval(interval);
227
230
  };
228
- }, [dbPath, typeFilter, refreshTick]);
231
+ }, [projectDir, typeFilter, refreshTick]);
229
232
 
230
233
  // Filter threads by search query
231
234
  const filteredThreads = useMemo(() => {
@@ -245,18 +248,16 @@ export const ThreadPanel = memo(function ThreadPanel({
245
248
  return;
246
249
  }
247
250
 
248
- withDb(dbPath, (conn) => getThread(conn, selectedThread.id)).then(
249
- (result) => {
250
- if (mounted && result) {
251
- setSelectedDetail(result);
252
- }
253
- },
254
- );
251
+ getThread(projectDir, selectedThread.id).then((result) => {
252
+ if (mounted && result) {
253
+ setSelectedDetail(result);
254
+ }
255
+ });
255
256
 
256
257
  return () => {
257
258
  mounted = false;
258
259
  };
259
- }, [dbPath, selectedThread?.id, following]);
260
+ }, [projectDir, selectedThread?.id, following]);
260
261
 
261
262
  // Follow mode: poll for new interactions every 1s
262
263
  // biome-ignore lint/correctness/useExhaustiveDependencies: following and selectedThread?.id are the intentional triggers
@@ -266,12 +267,10 @@ export const ThreadPanel = memo(function ThreadPanel({
266
267
 
267
268
  const poll = async () => {
268
269
  try {
269
- const newInteractions = await withDb(dbPath, (conn) =>
270
- getInteractionsAfter(
271
- conn,
272
- selectedThread.id,
273
- lastSeenSequenceRef.current,
274
- ),
270
+ const newInteractions = await getInteractionsAfter(
271
+ projectDir,
272
+ selectedThread.id,
273
+ lastSeenSequenceRef.current,
275
274
  );
276
275
  if (!mounted || newInteractions.length === 0) return;
277
276
 
@@ -287,24 +286,18 @@ export const ThreadPanel = memo(function ThreadPanel({
287
286
  };
288
287
  });
289
288
 
290
- // Auto-scroll will be handled by the detailLines/maxDetailScroll recalc
291
289
  setDetailScroll(Number.MAX_SAFE_INTEGER);
292
290
 
293
- const ended = await withDb(dbPath, (conn) =>
294
- isThreadEnded(conn, selectedThread.id),
295
- );
291
+ const ended = await isThreadEnded(projectDir, selectedThread.id);
296
292
  if (mounted && ended) {
297
293
  setFollowing(false);
298
- // Refresh the thread to get the ended_at timestamp
299
- const result = await withDb(dbPath, (conn) =>
300
- getThread(conn, selectedThread.id),
301
- );
294
+ const result = await getThread(projectDir, selectedThread.id);
302
295
  if (mounted && result) {
303
296
  setSelectedDetail(result);
304
297
  }
305
298
  }
306
299
  } catch {
307
- // Transient DB errors — retry next tick
300
+ // Transient FS errors — retry next tick
308
301
  }
309
302
  };
310
303
 
@@ -314,7 +307,7 @@ export const ThreadPanel = memo(function ThreadPanel({
314
307
  mounted = false;
315
308
  clearInterval(interval);
316
309
  };
317
- }, [dbPath, following, selectedThread?.id]);
310
+ }, [projectDir, following, selectedThread?.id]);
318
311
 
319
312
  const isActiveSelected = selectedThread?.id === activeThreadId;
320
313
 
@@ -383,9 +376,7 @@ export const ThreadPanel = memo(function ThreadPanel({
383
376
  if (confirmDelete) {
384
377
  if (input === "y" || input === "d") {
385
378
  if (selectedThread && !isActiveSelected) {
386
- withDb(dbPath, (conn) =>
387
- deleteThread(conn, selectedThread.id),
388
- ).then(() => {
379
+ deleteThread(projectDir, selectedThread.id).then(() => {
389
380
  forceRefresh();
390
381
  });
391
382
  }
@@ -1,11 +1,10 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
- import { withDb } from "../../db/connection.ts";
4
- import { listWorkers, type Worker } from "../../db/workers.ts";
5
3
  import { readLogTail } from "../../worker/log-reader.ts";
4
+ import { listWorkers, type Worker } from "../../workers/store.ts";
6
5
 
7
6
  interface WorkerPanelProps {
8
- dbPath: string;
7
+ projectDir: string;
9
8
  isActive: boolean;
10
9
  }
11
10
 
@@ -30,7 +29,9 @@ function statusColor(status: Worker["status"]): string {
30
29
  }
31
30
  }
32
31
 
33
- function formatAge(from: Date, now: Date): string {
32
+ function formatAge(fromIso: string, now: Date): string {
33
+ const from = new Date(fromIso);
34
+ if (Number.isNaN(from.getTime())) return "?";
34
35
  const secs = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000));
35
36
  if (secs < 60) return `${secs}s`;
36
37
  const mins = Math.floor(secs / 60);
@@ -47,7 +48,7 @@ function formatBytes(n: number): string {
47
48
  }
48
49
 
49
50
  export const WorkerPanel = memo(function WorkerPanel({
50
- dbPath,
51
+ projectDir,
51
52
  isActive,
52
53
  }: WorkerPanelProps) {
53
54
  const { stdout } = useStdout();
@@ -68,15 +69,17 @@ export const WorkerPanel = memo(function WorkerPanel({
68
69
 
69
70
  const refresh = async () => {
70
71
  const status = STATUS_FILTERS[filterIdx] ?? undefined;
71
- const result = await withDb(dbPath, (conn) =>
72
- listWorkers(conn, status ? { status } : {}),
73
- );
74
- if (mounted) {
75
- setWorkers(result);
76
- setNow(new Date());
77
- setSelectedIndex((prev) =>
78
- Math.min(prev, Math.max(0, result.length - 1)),
79
- );
72
+ try {
73
+ const result = await listWorkers(projectDir, status ? { status } : {});
74
+ if (mounted) {
75
+ setWorkers(result);
76
+ setNow(new Date());
77
+ setSelectedIndex((prev) =>
78
+ Math.min(prev, Math.max(0, result.length - 1)),
79
+ );
80
+ }
81
+ } catch {
82
+ // ignore — next tick retries
80
83
  }
81
84
  };
82
85
 
@@ -86,7 +89,7 @@ export const WorkerPanel = memo(function WorkerPanel({
86
89
  mounted = false;
87
90
  clearInterval(interval);
88
91
  };
89
- }, [dbPath, filterIdx]);
92
+ }, [projectDir, filterIdx]);
90
93
 
91
94
  const selected = workers[selectedIndex];
92
95
  const selectedLogPath = selected?.log_path ?? null;
@@ -332,18 +335,17 @@ function WorkerDetail({ worker, now }: { worker: Worker; now: Date }) {
332
335
  </Text>
333
336
  <Text>
334
337
  <Text dimColor>Started </Text>
335
- {worker.started_at.toISOString()}{" "}
338
+ {worker.started_at}{" "}
336
339
  <Text dimColor>({formatAge(worker.started_at, now)} ago)</Text>
337
340
  </Text>
338
341
  <Text>
339
- <Text dimColor>Heartbeat</Text>{" "}
340
- {worker.last_heartbeat_at.toISOString()}{" "}
342
+ <Text dimColor>Heartbeat</Text> {worker.last_heartbeat_at}{" "}
341
343
  <Text dimColor>({formatAge(worker.last_heartbeat_at, now)} ago)</Text>
342
344
  </Text>
343
345
  {worker.stopped_at && (
344
346
  <Text>
345
347
  <Text dimColor>Stopped </Text>
346
- {worker.stopped_at.toISOString()}
348
+ {worker.stopped_at}
347
349
  </Text>
348
350
  )}
349
351
  {worker.task_id && (
@@ -1,14 +1,8 @@
1
- import type { ContextItem } from "../db/context.ts";
2
-
3
1
  export function renderMarkdown(text: string): string {
4
2
  if (!text) return "";
5
3
  return Bun.markdown.ansi(text).trimEnd();
6
4
  }
7
5
 
8
- export function isMarkdownItem(
9
- item: Pick<ContextItem, "mime_type" | "path">,
10
- ): boolean {
11
- if (item.mime_type === "text/markdown") return true;
12
- if (item.path.toLowerCase().endsWith(".md")) return true;
13
- return false;
6
+ export function isMarkdownPath(path: string): boolean {
7
+ return path.toLowerCase().endsWith(".md");
14
8
  }