botholomew 0.15.2 → 0.15.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
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/chat/agent.ts CHANGED
@@ -35,11 +35,13 @@ import {
35
35
 
36
36
  registerAllTools();
37
37
 
38
- /** Tools available in chat mode — no worker terminal tools (complete/fail/wait), no bulk-destructive file tools (delete, copy/move, dir ops) */
38
+ /** Tools available in chat mode — no worker terminal tools (complete/fail/wait), no bulk-destructive file tools (copy/move, dir ops) */
39
39
  const CHAT_TOOL_NAMES = new Set([
40
40
  "create_task",
41
41
  "list_tasks",
42
42
  "view_task",
43
+ "update_task",
44
+ "delete_task",
43
45
  "context_info",
44
46
  "context_tree",
45
47
  "context_read",
@@ -156,6 +158,11 @@ export interface ChatTurnCallbacks {
156
158
  isError: boolean,
157
159
  meta?: ToolEndMeta,
158
160
  ) => void;
161
+ /** Side-effect notification from inside a tool ("Created subtask: …"). The
162
+ * TUI renders these inside the tool-call card so they stay anchored to the
163
+ * tool that produced them. Workers don't supply this; tools fall back to
164
+ * `logger.info`. */
165
+ onToolNotify?: (toolUseId: string, message: string) => void;
159
166
  /** Called between LLM turns. The TUI returns any queued user messages so
160
167
  * the agent can inject them into the running turn instead of waiting for
161
168
  * the entire tool loop to finish. Each returned message is logged + pushed
@@ -406,6 +413,9 @@ export async function runChatTurn(input: {
406
413
  config,
407
414
  mcpxClient,
408
415
  shouldAbort: session ? () => session.aborted : undefined,
416
+ notify: callbacks.onToolNotify
417
+ ? (msg) => callbacks.onToolNotify?.(toolUse.id, msg)
418
+ : undefined,
409
419
  });
410
420
  const durationMs = Date.now() - start;
411
421
  const stored = maybeStoreResult(toolUse.name, result.output);
@@ -454,6 +464,7 @@ interface ChatToolCallCtx {
454
464
  config: Required<BotholomewConfig>;
455
465
  mcpxClient: McpxClient | null;
456
466
  shouldAbort?: () => boolean;
467
+ notify?: (message: string) => void;
457
468
  }
458
469
 
459
470
  async function executeChatToolCall(
@@ -8,8 +8,9 @@ export function registerChatCommand(program: Command) {
8
8
  "Open the interactive chat TUI\n\n" +
9
9
  " Tab navigation (Ctrl+<letter> from any tab):\n" +
10
10
  " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
11
- " Ctrl+o Tools Ctrl+r Threads Ctrl+g Help\n" +
11
+ " Ctrl+o Tools Ctrl+e Threads Ctrl+g Help\n" +
12
12
  " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
13
+ " Refresh: Ctrl+R refreshes Context · Tasks · Threads · Schedules · Workers\n\n" +
13
14
  " Chat input:\n" +
14
15
  " Enter Send message\n" +
15
16
  " ⌥+Enter Insert newline\n" +
@@ -33,7 +33,9 @@ export const createScheduleTool = {
33
33
  description: input.description,
34
34
  frequency: input.frequency,
35
35
  });
36
- logger.info(`Created schedule: ${schedule.name} (${schedule.id})`);
36
+ const msg = `Created schedule: ${schedule.name} (${schedule.id})`;
37
+ if (ctx.notify) ctx.notify(msg);
38
+ else logger.info(msg);
37
39
  return {
38
40
  id: schedule.id,
39
41
  name: schedule.name,
@@ -55,7 +55,9 @@ export const createTaskTool = {
55
55
  blocked_by: input.blocked_by,
56
56
  context_paths: input.context_paths,
57
57
  });
58
- logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
58
+ const msg = `Created subtask: ${newTask.name} (${newTask.id})`;
59
+ if (ctx.notify) ctx.notify(msg);
60
+ else logger.info(msg);
59
61
  return {
60
62
  id: newTask.id,
61
63
  name: newTask.name,
@@ -44,7 +44,9 @@ export const deleteTaskTool = {
44
44
  is_error: true,
45
45
  };
46
46
  }
47
- logger.info(`Deleted task: ${existing.name} (${existing.id})`);
47
+ const msg = `Deleted task: ${existing.name} (${existing.id})`;
48
+ if (ctx.notify) ctx.notify(msg);
49
+ else logger.info(msg);
48
50
  return {
49
51
  deleted_id: existing.id,
50
52
  message: `Deleted task "${existing.name}" (${existing.id})`,
@@ -91,7 +91,9 @@ export const updateTaskTool = {
91
91
  };
92
92
  }
93
93
 
94
- logger.info(`Updated task: ${updated.name} (${updated.id})`);
94
+ const msg = `Updated task: ${updated.name} (${updated.id})`;
95
+ if (ctx.notify) ctx.notify(msg);
96
+ else logger.info(msg);
95
97
  return {
96
98
  task: {
97
99
  id: updated.id,
package/src/tools/tool.ts CHANGED
@@ -22,6 +22,13 @@ export interface ToolContext {
22
22
  * Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
23
23
  */
24
24
  shouldAbort?: () => boolean;
25
+ /**
26
+ * Chat-mode only. Tools call this to surface a short human-readable
27
+ * side-effect message (e.g. "Created subtask: …") that the TUI renders
28
+ * inside the tool-call card. Workers leave this `undefined`; tools fall
29
+ * back to `logger.info` so worker logs are unchanged.
30
+ */
31
+ notify?: (message: string) => void;
25
32
  }
26
33
 
27
34
  type ToolOutputBase = { is_error: z.ZodBoolean };
package/src/tui/App.tsx CHANGED
@@ -65,7 +65,7 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
65
65
  o: 2, // t[o]ols
66
66
  n: 3, // co[n]text
67
67
  t: 4, // [t]asks
68
- r: 5, // th[r]eads
68
+ e: 5, // thr[e]ads
69
69
  s: 6, // [s]chedules
70
70
  w: 7, // [w]orkers
71
71
  g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
@@ -328,9 +328,18 @@ function AppInner({
328
328
  slashCommandsRef.current,
329
329
  );
330
330
  if (popupOpen) return;
331
+ // Ctrl+E edits a queued message when one is selected; only
332
+ // fall through to the Threads tab-jump when the queue is empty.
333
+ if (input === "e" && queuedMessagesRef.current.length > 0) {
334
+ // handled by the queue keybindings block below
335
+ } else {
336
+ setActiveTab(tabForKey);
337
+ return;
338
+ }
339
+ } else {
340
+ setActiveTab(tabForKey);
341
+ return;
331
342
  }
332
- setActiveTab(tabForKey);
333
- return;
334
343
  }
335
344
  }
336
345
 
@@ -476,6 +485,14 @@ function AppInner({
476
485
  }
477
486
  setActiveToolCalls([...pendingToolCalls]);
478
487
  },
488
+ onToolNotify: (id, message) => {
489
+ markActivityRef.current();
490
+ const tc = pendingToolCalls.find((t) => t.id === id);
491
+ if (tc) {
492
+ tc.notes = [...(tc.notes ?? []), message];
493
+ setActiveToolCalls([...pendingToolCalls]);
494
+ }
495
+ },
479
496
  onUsage: (info) => {
480
497
  setUsage(info);
481
498
  },
@@ -236,8 +236,9 @@ export const ContextPanel = memo(function ContextPanel({
236
236
  deleteConfirm.pressDelete(entry.path);
237
237
  return;
238
238
  }
239
- if (input === "r") {
239
+ if (key.ctrl && (input === "r" || input === "R")) {
240
240
  refresh(currentPathRef.current);
241
+ return;
241
242
  }
242
243
  },
243
244
  { isActive },
@@ -367,7 +368,7 @@ export const ContextPanel = memo(function ContextPanel({
367
368
  <Text dimColor>
368
369
  {focus === "detail"
369
370
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
370
- : "↑↓ select · → drill in/enter detail · ← up · d delete (×2) · r refresh"}
371
+ : "↑↓ select · → drill in/enter detail · ← up · d delete (×2) · ^R refresh"}
371
372
  </Text>
372
373
  </Box>
373
374
  </Box>
@@ -400,7 +401,7 @@ function ContextDetailHeader({
400
401
  indexLoaded: boolean;
401
402
  }) {
402
403
  return (
403
- <Box flexDirection="column">
404
+ <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
404
405
  <Box>
405
406
  <Text bold color="cyan" wrap="truncate-end">
406
407
  {entry.is_directory ? "📁" : "📄"} context/{entry.path}
@@ -441,9 +442,6 @@ function ContextDetailHeader({
441
442
  </Box>
442
443
  </>
443
444
  )}
444
- <Box>
445
- <Text dimColor>{"─".repeat(2)}</Text>
446
- </Box>
447
445
  </Box>
448
446
  );
449
447
  }
@@ -56,7 +56,7 @@ export const HelpPanel = memo(function HelpPanel({
56
56
  {" "}Ctrl+t{" "}Tasks
57
57
  </Text>
58
58
  <Text>
59
- {" "}Ctrl+r{" "}Threads
59
+ {" "}Ctrl+e{" "}Threads
60
60
  </Text>
61
61
  <Text>
62
62
  {" "}Ctrl+s{" "}Schedules
@@ -142,19 +142,21 @@ export const HelpPanel = memo(function HelpPanel({
142
142
  (cancels on any other key or after 3s)
143
143
  </Text>
144
144
  <Text>
145
- {" "}Tasks{" "}f filter · p priority · d delete (×2) · r
146
- refresh
145
+ {" "}Ctrl+R{" "}Refresh (Context · Tasks · Threads ·
146
+ Schedules · Workers)
147
+ </Text>
148
+ <Text>
149
+ {" "}Tasks{" "}f filter · p priority · d delete (×2)
147
150
  </Text>
148
151
  <Text>
149
152
  {" "}Threads{" "}f filter · s/ search · w follow · d delete
150
- (×2) · r refresh
153
+ (×2)
151
154
  </Text>
152
155
  <Text>
153
- {" "}Schedules{" "}f filter · e toggle · d delete (×2) · r
154
- refresh
156
+ {" "}Schedules{" "}f filter · e toggle · d delete (×2)
155
157
  </Text>
156
158
  <Text>
157
- {" "}Context{" "}d delete (×2) · r refresh
159
+ {" "}Context{" "}d delete (×2)
158
160
  </Text>
159
161
  <Text>
160
162
  {" "}Workers{" "}f filter · l toggle log/detail · d delete log
@@ -207,7 +207,7 @@ export const SchedulePanel = memo(function SchedulePanel({
207
207
  deleteConfirm.pressDelete(s.name);
208
208
  return;
209
209
  }
210
- if (input === "r") {
210
+ if (key.ctrl && (input === "r" || input === "R")) {
211
211
  forceRefresh();
212
212
  return;
213
213
  }
@@ -338,7 +338,7 @@ export const SchedulePanel = memo(function SchedulePanel({
338
338
  <Text dimColor>
339
339
  {focus === "detail"
340
340
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
341
- : "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · r refresh"}
341
+ : "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · ^R refresh"}
342
342
  </Text>
343
343
  </Box>
344
344
  </Box>
@@ -348,7 +348,7 @@ export const SchedulePanel = memo(function SchedulePanel({
348
348
  function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
349
349
  const enabledKey = String(schedule.enabled);
350
350
  return (
351
- <Box flexDirection="column">
351
+ <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
352
352
  <Box>
353
353
  <Text bold color={theme.info} wrap="truncate-end">
354
354
  {schedule.name}
@@ -367,9 +367,6 @@ function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
367
367
  </Text>
368
368
  </Text>
369
369
  </Box>
370
- <Box>
371
- <Text dimColor>{"─".repeat(2)}</Text>
372
- </Box>
373
370
  </Box>
374
371
  );
375
372
  }
@@ -241,7 +241,7 @@ export const TaskPanel = memo(function TaskPanel({
241
241
  deleteConfirm.pressDelete(t.name || t.id);
242
242
  return;
243
243
  }
244
- if (input === "r") {
244
+ if (key.ctrl && (input === "r" || input === "R")) {
245
245
  forceRefresh();
246
246
  return;
247
247
  }
@@ -376,7 +376,7 @@ export const TaskPanel = memo(function TaskPanel({
376
376
  <Text dimColor>
377
377
  {focus === "detail"
378
378
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
379
- : "↑↓ select · → enter detail · f filter · p priority · d delete (×2) · r refresh"}
379
+ : "↑↓ select · → enter detail · f filter · p priority · d delete (×2) · ^R refresh"}
380
380
  </Text>
381
381
  </Box>
382
382
  </Box>
@@ -385,7 +385,7 @@ export const TaskPanel = memo(function TaskPanel({
385
385
 
386
386
  function TaskDetailHeader({ task }: { task: Task }) {
387
387
  return (
388
- <Box flexDirection="column">
388
+ <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
389
389
  <Box>
390
390
  <Text bold color={theme.info} wrap="truncate-end">
391
391
  {task.name}
@@ -408,9 +408,6 @@ function TaskDetailHeader({ task }: { task: Task }) {
408
408
  </Text>
409
409
  </Text>
410
410
  </Box>
411
- <Box>
412
- <Text dimColor>{"─".repeat(2)}</Text>
413
- </Box>
414
411
  </Box>
415
412
  );
416
413
  }
@@ -394,7 +394,7 @@ export const ThreadPanel = memo(function ThreadPanel({
394
394
  deleteConfirm.pressDelete(t.title || "(untitled)");
395
395
  return;
396
396
  }
397
- if (input === "r") {
397
+ if (key.ctrl && (input === "r" || input === "R")) {
398
398
  forceRefresh();
399
399
  return;
400
400
  }
@@ -575,7 +575,7 @@ export const ThreadPanel = memo(function ThreadPanel({
575
575
  <Text dimColor>
576
576
  {focus === "detail"
577
577
  ? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
578
- : `↑↓ select · → enter detail · s search · f filter · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
578
+ : `↑↓ select · → enter detail · s search · f filter · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · ^R refresh`}
579
579
  </Text>
580
580
  </Box>
581
581
  </Box>
@@ -591,7 +591,7 @@ function ThreadDetailHeader({
591
591
  isActiveThread: boolean;
592
592
  }) {
593
593
  return (
594
- <Box flexDirection="column">
594
+ <Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
595
595
  <Box>
596
596
  <Text wrap="truncate-end">
597
597
  <Text bold italic color={theme.info}>
@@ -625,9 +625,6 @@ function ThreadDetailHeader({
625
625
  </Text>
626
626
  </Text>
627
627
  </Box>
628
- <Box>
629
- <Text dimColor>{"─".repeat(2)}</Text>
630
- </Box>
631
628
  </Box>
632
629
  );
633
630
  }
@@ -40,6 +40,8 @@ export interface ToolCallData {
40
40
  timestamp: Date;
41
41
  largeResult?: LargeResultMeta;
42
42
  isError?: boolean;
43
+ /** Side-effect notes emitted by the tool (e.g. "Created subtask: …"). */
44
+ notes?: string[];
43
45
  }
44
46
 
45
47
  interface ToolCallProps {
@@ -106,6 +108,18 @@ export function ToolCall({ tool }: ToolCallProps) {
106
108
  {tool.largeResult.pages}pg]
107
109
  </Text>
108
110
  )}
111
+ {tool.notes?.map((note, i) => (
112
+ <Text
113
+ // biome-ignore lint/suspicious/noArrayIndexKey: notes are append-only
114
+ key={i}
115
+ color={theme.accent}
116
+ dimColor
117
+ wrap="truncate-end"
118
+ >
119
+ {" ℹ "}
120
+ {note}
121
+ </Text>
122
+ ))}
109
123
  </Box>
110
124
  );
111
125
  }
@@ -1,6 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { Box, Text, useInput, useStdout } from "ink";
3
- import { memo, useEffect, useMemo, useState } from "react";
3
+ import { memo, useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { readLogTail } from "../../worker/log-reader.ts";
5
5
  import {
6
6
  deleteWorkerLog,
@@ -70,6 +70,7 @@ export const WorkerPanel = memo(function WorkerPanel({
70
70
  const [workers, setWorkers] = useState<Worker[]>([]);
71
71
  const [selectedIndex, setSelectedIndex] = useState(0);
72
72
  const [filterIdx, setFilterIdx] = useState(0);
73
+ const [refreshTick, setRefreshTick] = useState(0);
73
74
  const [now, setNow] = useState(() => new Date());
74
75
  const [viewMode, setViewMode] = useState<"detail" | "log">("detail");
75
76
  const [logContent, setLogContent] = useState("");
@@ -79,6 +80,7 @@ export const WorkerPanel = memo(function WorkerPanel({
79
80
  const [logFollow, setLogFollow] = useState(true);
80
81
  const [focus, setFocus] = useState<FocusState>("list");
81
82
 
83
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
82
84
  useEffect(() => {
83
85
  let mounted = true;
84
86
 
@@ -104,7 +106,7 @@ export const WorkerPanel = memo(function WorkerPanel({
104
106
  mounted = false;
105
107
  clearInterval(interval);
106
108
  };
107
- }, [projectDir, filterIdx]);
109
+ }, [projectDir, filterIdx, refreshTick]);
108
110
 
109
111
  const selected = workers[selectedIndex];
110
112
  const selectedLogPath = selected?.log_path ?? null;
@@ -171,6 +173,10 @@ export const WorkerPanel = memo(function WorkerPanel({
171
173
  const viewModeRef = useLatestRef(viewMode);
172
174
  const selectedLogPathRef = useLatestRef(selectedLogPath);
173
175
 
176
+ const forceRefresh = useCallback(() => {
177
+ setRefreshTick((t) => t + 1);
178
+ }, []);
179
+
174
180
  const deleteConfirm = useDeleteConfirm(() => {
175
181
  const path = selectedLogPathRef.current;
176
182
  if (!path) return;
@@ -238,6 +244,11 @@ export const WorkerPanel = memo(function WorkerPanel({
238
244
  deleteConfirm.pressDelete(`worker log: ${basename(path)}`);
239
245
  return;
240
246
  }
247
+
248
+ if (key.ctrl && (input === "r" || input === "R")) {
249
+ forceRefresh();
250
+ return;
251
+ }
241
252
  },
242
253
  { isActive },
243
254
  );
@@ -257,8 +268,8 @@ export const WorkerPanel = memo(function WorkerPanel({
257
268
  {focus === "detail"
258
269
  ? " · ↑↓ scroll ⇧↑↓ page g/G top/bot ← back to list l toggle"
259
270
  : viewMode === "log"
260
- ? " · ↑↓ select → enter log l detail f filter d delete log (×2)"
261
- : " · ↑↓ select → enter detail l view log f filter"}
271
+ ? " · ↑↓ select → enter log l detail f filter d delete log (×2) ^R refresh"
272
+ : " · ↑↓ select → enter detail l view log f filter ^R refresh"}
262
273
  </Text>
263
274
  </Box>
264
275
  <DeleteArmedBanner
package/src/tui/theme.ts CHANGED
@@ -49,6 +49,7 @@ export const theme = {
49
49
  accentBorder: isDark ? "yellow" : "#B8860B",
50
50
  userBg: isDark ? "#2a5a8c" : "#d0e0f0",
51
51
  selectionBg: isDark ? "#333" : "#ddd",
52
+ headerBg: isDark ? "#3a4655" : "#f5f7fa",
52
53
  success: "green",
53
54
  error: "red",
54
55
  info: "cyan",