botholomew 0.15.0 → 0.15.1

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
@@ -76,6 +76,8 @@ Requires [Bun](https://bun.sh) 1.1+.
76
76
  bun install -g botholomew
77
77
  ```
78
78
 
79
+ The CLI installs as both `botholomew` and `bothy` — the same binary, two names.
80
+
79
81
  Or run the dev build from a checkout:
80
82
 
81
83
  ```bash
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "botholomew": "./src/cli.ts"
7
+ "botholomew": "./src/cli.ts",
8
+ "bothy": "./src/cli.ts"
8
9
  },
9
10
  "files": [
10
11
  "src",
@@ -1,4 +1,5 @@
1
1
  import type { Command } from "commander";
2
+ import { loadConfig } from "../config/loader.ts";
2
3
 
3
4
  export function registerChatCommand(program: Command) {
4
5
  program
@@ -29,6 +30,8 @@ export function registerChatCommand(program: Command) {
29
30
  const React = await import("react");
30
31
  const { App } = await import("../tui/App.tsx");
31
32
  const dir = program.opts().dir;
33
+ const config = await loadConfig(dir);
34
+ const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
32
35
 
33
36
  // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
34
37
  // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
@@ -41,6 +44,7 @@ export function registerChatCommand(program: Command) {
41
44
  projectDir: dir,
42
45
  threadId: opts.threadId,
43
46
  initialPrompt: opts.prompt,
47
+ idleTimeoutMs,
44
48
  }),
45
49
  {
46
50
  exitOnCtrlC: false,
@@ -14,6 +14,7 @@ export interface BotholomewConfig {
14
14
  worker_stopped_retention_seconds?: number;
15
15
  schedule_min_interval_seconds?: number;
16
16
  schedule_claim_stale_seconds?: number;
17
+ tui_idle_timeout_seconds?: number;
17
18
  log_level?: string;
18
19
  }
19
20
 
@@ -33,5 +34,6 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
33
34
  worker_stopped_retention_seconds: 3600,
34
35
  schedule_min_interval_seconds: 60,
35
36
  schedule_claim_stale_seconds: 300,
37
+ tui_idle_timeout_seconds: 180,
36
38
  log_level: "",
37
39
  };
@@ -39,7 +39,7 @@ export interface FetchedContent {
39
39
  /**
40
40
  * MCP server that produced the content (e.g. "google-docs", "github",
41
41
  * "firecrawl"), or null when we fell back to a plain HTTP fetch. Useful
42
- * for `bothy context import` to pick a default destination subdirectory.
42
+ * for `botholomew context import` to pick a default destination subdirectory.
43
43
  */
44
44
  source: string | null;
45
45
  }
package/src/tui/App.tsx CHANGED
@@ -33,6 +33,7 @@ import { ThreadPanel } from "./components/ThreadPanel.tsx";
33
33
  import type { ToolCallData } from "./components/ToolCall.tsx";
34
34
  import { ToolPanel } from "./components/ToolPanel.tsx";
35
35
  import { WorkerPanel } from "./components/WorkerPanel.tsx";
36
+ import { IdleProvider, useIdle } from "./idle.tsx";
36
37
  import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
37
38
  import { ansi } from "./theme.ts";
38
39
 
@@ -40,6 +41,7 @@ interface AppProps {
40
41
  projectDir: string;
41
42
  threadId?: string;
42
43
  initialPrompt?: string;
44
+ idleTimeoutMs: number;
43
45
  }
44
46
 
45
47
  let nextMsgId = 0;
@@ -138,8 +140,32 @@ export function App({
138
140
  projectDir,
139
141
  threadId: resumeThreadId,
140
142
  initialPrompt,
143
+ idleTimeoutMs,
141
144
  }: AppProps) {
145
+ return (
146
+ <IdleProvider timeoutMs={idleTimeoutMs}>
147
+ <AppInner
148
+ projectDir={projectDir}
149
+ threadId={resumeThreadId}
150
+ initialPrompt={initialPrompt}
151
+ />
152
+ </IdleProvider>
153
+ );
154
+ }
155
+
156
+ interface AppInnerProps {
157
+ projectDir: string;
158
+ threadId?: string;
159
+ initialPrompt?: string;
160
+ }
161
+
162
+ function AppInner({
163
+ projectDir,
164
+ threadId: resumeThreadId,
165
+ initialPrompt,
166
+ }: AppInnerProps) {
142
167
  const { exit } = useApp();
168
+ const { markActivity } = useIdle();
143
169
  const [messages, setMessages] = useState<ChatMessage[]>([]);
144
170
  const [messagesEpoch, setMessagesEpoch] = useState(0);
145
171
  const [inputValue, setInputValue] = useState("");
@@ -265,9 +291,14 @@ export function App({
265
291
  const slashCommandsRef = useRef<SlashCommand[]>([]);
266
292
  const inputValueRef = useRef("");
267
293
 
294
+ const markActivityRef = useRef(markActivity);
295
+ markActivityRef.current = markActivity;
296
+
268
297
  const stableAppHandler = useCallback(
269
298
  // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
270
299
  (input: string, key: any) => {
300
+ markActivityRef.current();
301
+
271
302
  // Ctrl+C exits
272
303
  if (input === "c" && key.ctrl) {
273
304
  exit();
@@ -407,12 +438,15 @@ export function App({
407
438
  if (now - lastStreamFlush >= 50) {
408
439
  setStreamingText(currentText);
409
440
  lastStreamFlush = now;
441
+ markActivityRef.current();
410
442
  }
411
443
  },
412
444
  onToolPreparing: (id, name) => {
445
+ markActivityRef.current();
413
446
  setPreparingTool({ id, name });
414
447
  },
415
448
  onToolStart: (id, name, input) => {
449
+ markActivityRef.current();
416
450
  if (currentText) {
417
451
  finalizeSegment();
418
452
  }
@@ -428,6 +462,7 @@ export function App({
428
462
  setPreparingTool(null);
429
463
  },
430
464
  onToolEnd: (id, _name, output, isError, meta) => {
465
+ markActivityRef.current();
431
466
  const tc = pendingToolCalls.find((t) => t.id === id);
432
467
  if (tc) {
433
468
  tc.running = false;
@@ -9,6 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import type { SlashCommand } from "../../skills/commands.ts";
12
+ import { useIdle } from "../idle.tsx";
12
13
  import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
13
14
  import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
14
15
 
@@ -38,6 +39,7 @@ export const InputBar = memo(function InputBar({
38
39
  const [popupDismissed, setPopupDismissed] = useState(false);
39
40
  const savedInput = useRef("");
40
41
  const lastActivity = useRef(Date.now());
42
+ const { isIdle } = useIdle();
41
43
 
42
44
  // Refs for values read inside the input handler — eagerly updated so rapid
43
45
  // keystrokes that arrive before React re-renders always see fresh state.
@@ -94,7 +96,7 @@ export const InputBar = memo(function InputBar({
94
96
  // Blink cursor when input is active — skip ticks while typing so the
95
97
  // cursor stays solid and we avoid unnecessary renders during rapid input.
96
98
  useEffect(() => {
97
- if (disabled) {
99
+ if (disabled || isIdle) {
98
100
  setCursorVisible(true);
99
101
  return;
100
102
  }
@@ -105,7 +107,7 @@ export const InputBar = memo(function InputBar({
105
107
  setCursorVisible((prev) => (prev === phase ? prev : phase));
106
108
  }, 530);
107
109
  return () => clearInterval(id);
108
- }, [disabled]);
110
+ }, [disabled, isIdle]);
109
111
 
110
112
  // Stable input handler — the callback reference never changes, which
111
113
  // prevents Ink's useInput from removing/re-adding the stdin listener on
@@ -337,14 +339,14 @@ export const InputBar = memo(function InputBar({
337
339
  <Box
338
340
  flexDirection="column"
339
341
  borderStyle="single"
340
- borderColor={disabled ? "gray" : "green"}
342
+ borderColor={disabled || isIdle ? "gray" : "green"}
341
343
  paddingX={1}
342
344
  >
343
345
  {header}
344
346
  {!disabled && (
345
347
  <Box flexDirection="column">
346
348
  <Box>
347
- <Text color="green">{"› "}</Text>
349
+ <Text color={isIdle ? "gray" : "green"}>{"› "}</Text>
348
350
  {placeholder ? (
349
351
  <Text dimColor>Type a message...</Text>
350
352
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  const STARTUP_FRAMES = [
@@ -23,8 +24,10 @@ const IDLE_MS = 2000;
23
24
  export function AnimatedLogo() {
24
25
  const [frameIndex, setFrameIndex] = useState(0);
25
26
  const [startupDone, setStartupDone] = useState(false);
27
+ const { isIdle } = useIdle();
26
28
 
27
29
  useEffect(() => {
30
+ if (isIdle) return;
28
31
  const interval = setInterval(
29
32
  () => {
30
33
  setFrameIndex((prev) => {
@@ -42,20 +45,21 @@ export function AnimatedLogo() {
42
45
  startupDone ? IDLE_MS : STARTUP_MS,
43
46
  );
44
47
  return () => clearInterval(interval);
45
- }, [startupDone]);
48
+ }, [startupDone, isIdle]);
46
49
 
47
50
  const frames = startupDone ? IDLE_FRAMES : STARTUP_FRAMES;
48
51
  // biome-ignore lint: frameIndex is always in bounds
49
52
  const frame = frames[frameIndex]!;
53
+ const color = isIdle ? "gray" : theme.accent;
50
54
 
51
55
  return (
52
56
  <Box flexDirection="column" alignItems="center" justifyContent="center">
53
57
  {frame.map((line) => (
54
- <Text key={line} color={theme.accent}>
58
+ <Text key={line} color={color}>
55
59
  {line}
56
60
  </Text>
57
61
  ))}
58
- <Text bold color={theme.accent}>
62
+ <Text bold color={color}>
59
63
  Botholomew
60
64
  </Text>
61
65
  <Text dimColor>Starting chat session...</Text>
@@ -67,13 +71,19 @@ const CHAR_FRAMES = ["{o,o}", "{o,o}", "{-,-}", "{o,o}"];
67
71
 
68
72
  export function LogoChar() {
69
73
  const [frameIndex, setFrameIndex] = useState(0);
74
+ const { isIdle } = useIdle();
70
75
 
71
76
  useEffect(() => {
77
+ if (isIdle) return;
72
78
  const interval = setInterval(() => {
73
79
  setFrameIndex((prev) => (prev + 1) % CHAR_FRAMES.length);
74
80
  }, IDLE_MS);
75
81
  return () => clearInterval(interval);
76
- }, []);
82
+ }, [isIdle]);
77
83
 
78
- return <Text color={theme.accent}>{CHAR_FRAMES[frameIndex]} </Text>;
84
+ return (
85
+ <Text color={isIdle ? "gray" : theme.accent}>
86
+ {CHAR_FRAMES[frameIndex]}{" "}
87
+ </Text>
88
+ );
79
89
  }
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
+ import { useIdle } from "../idle.tsx";
3
4
  import { theme } from "../theme.ts";
4
5
 
5
6
  interface SleepProgressProps {
@@ -17,11 +18,13 @@ export function SleepProgress({
17
18
  reason,
18
19
  }: SleepProgressProps) {
19
20
  const [now, setNow] = useState(() => Date.now());
21
+ const { isIdle } = useIdle();
20
22
 
21
23
  useEffect(() => {
24
+ if (isIdle) return;
22
25
  const id = setInterval(() => setNow(Date.now()), TICK_MS);
23
26
  return () => clearInterval(id);
24
- }, []);
27
+ }, [isIdle]);
25
28
 
26
29
  const totalMs = totalSeconds * 1000;
27
30
  const elapsedMs = Math.min(totalMs, Math.max(0, now - startedAt.getTime()));
@@ -34,7 +37,7 @@ export function SleepProgress({
34
37
  <Box flexDirection="column">
35
38
  <Box>
36
39
  <Text dimColor>{" "}</Text>
37
- <Text color={theme.accent}>{bar}</Text>
40
+ <Text color={isIdle ? "gray" : theme.accent}>{bar}</Text>
38
41
  <Text dimColor>
39
42
  {" "}
40
43
  {elapsedSec}s / {totalSeconds}s
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
3
  import { listTasks } from "../../tasks/store.ts";
4
4
  import { listWorkers } from "../../workers/store.ts";
5
+ import { useIdle } from "../idle.tsx";
5
6
  import { LogoChar } from "./Logo.tsx";
6
7
 
7
8
  interface StatusBarProps {
@@ -32,8 +33,10 @@ export function StatusBar({
32
33
  pendingCount: 0,
33
34
  inProgressCount: 0,
34
35
  });
36
+ const { isIdle } = useIdle();
35
37
 
36
38
  useEffect(() => {
39
+ if (isIdle) return;
37
40
  let mounted = true;
38
41
 
39
42
  // Errors here (e.g. transient DuckDB lock conflicts while a freshly
@@ -66,32 +69,32 @@ export function StatusBar({
66
69
  mounted = false;
67
70
  clearInterval(interval);
68
71
  };
69
- }, [projectDir, onWorkerStatusChange]);
72
+ }, [projectDir, onWorkerStatusChange, isIdle]);
70
73
 
71
74
  return (
72
75
  <Box paddingX={0}>
73
76
  <LogoChar />
74
- <Text bold color="blue">
77
+ <Text bold color={isIdle ? "gray" : "blue"}>
75
78
  Botholomew
76
79
  </Text>
77
80
  {chatTitle && (
78
81
  <>
79
82
  <Text dimColor> | </Text>
80
- <Text color="cyan" bold italic>
83
+ <Text color={isIdle ? "gray" : "cyan"} bold italic>
81
84
  {chatTitle.length > 30 ? `${chatTitle.slice(0, 29)}…` : chatTitle}
82
85
  </Text>
83
86
  </>
84
87
  )}
85
88
  <Text dimColor> | </Text>
86
89
  {status.workerCount > 0 ? (
87
- <Text color="green">
90
+ <Text color={isIdle ? "gray" : "green"}>
88
91
  {status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
89
92
  </Text>
90
93
  ) : (
91
- <Text color="yellow">no workers</Text>
94
+ <Text color={isIdle ? "gray" : "yellow"}>no workers</Text>
92
95
  )}
93
96
  <Text dimColor> | </Text>
94
- <Text>
97
+ <Text dimColor={isIdle}>
95
98
  {status.pendingCount} pending, {status.inProgressCount} active
96
99
  </Text>
97
100
  </Box>
@@ -0,0 +1,68 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+
11
+ const CHECK_INTERVAL_MS = 10_000;
12
+
13
+ export function shouldBeIdle(
14
+ lastActivity: number,
15
+ now: number,
16
+ timeoutMs: number,
17
+ ): boolean {
18
+ if (timeoutMs <= 0) return false;
19
+ return now - lastActivity >= timeoutMs;
20
+ }
21
+
22
+ interface IdleContextValue {
23
+ isIdle: boolean;
24
+ markActivity: () => void;
25
+ }
26
+
27
+ const IdleContext = createContext<IdleContextValue>({
28
+ isIdle: false,
29
+ markActivity: () => {},
30
+ });
31
+
32
+ interface IdleProviderProps {
33
+ timeoutMs: number;
34
+ children: ReactNode;
35
+ }
36
+
37
+ export function IdleProvider({ timeoutMs, children }: IdleProviderProps) {
38
+ const lastActivityRef = useRef(Date.now());
39
+ const [isIdle, setIsIdle] = useState(false);
40
+ const isIdleRef = useRef(isIdle);
41
+ isIdleRef.current = isIdle;
42
+
43
+ const markActivity = useCallback(() => {
44
+ lastActivityRef.current = Date.now();
45
+ if (isIdleRef.current) {
46
+ setIsIdle(false);
47
+ }
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ if (timeoutMs <= 0) return;
52
+ const id = setInterval(() => {
53
+ const idle = shouldBeIdle(lastActivityRef.current, Date.now(), timeoutMs);
54
+ setIsIdle((prev) => (prev === idle ? prev : idle));
55
+ }, CHECK_INTERVAL_MS);
56
+ return () => clearInterval(id);
57
+ }, [timeoutMs]);
58
+
59
+ return (
60
+ <IdleContext.Provider value={{ isIdle, markActivity }}>
61
+ {children}
62
+ </IdleContext.Provider>
63
+ );
64
+ }
65
+
66
+ export function useIdle(): IdleContextValue {
67
+ return useContext(IdleContext);
68
+ }
@@ -3,7 +3,7 @@ import matter from "gray-matter";
3
3
  export interface ContextFileMeta {
4
4
  loading?: "always" | "contextual";
5
5
  "agent-modification"?: boolean;
6
- // Set by `bothy context import <url>` so the saved file remembers
6
+ // Set by `botholomew context import <url>` so the saved file remembers
7
7
  // where it came from. Optional so files written by other paths
8
8
  // (prompts/, beliefs/, agent-authored notes) aren't required to
9
9
  // carry import metadata.