botholomew 0.15.0 → 0.15.2

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.
@@ -1,13 +1,20 @@
1
+ import { basename } from "node:path";
1
2
  import { Box, Text, useInput, useStdout } from "ink";
2
3
  import { memo, useEffect, useMemo, useState } from "react";
3
4
  import { readLogTail } from "../../worker/log-reader.ts";
4
- import { listWorkers, type Worker } from "../../workers/store.ts";
5
+ import {
6
+ deleteWorkerLog,
7
+ listWorkers,
8
+ type Worker,
9
+ } from "../../workers/store.ts";
5
10
  import {
6
11
  detailPaneBorderProps,
7
12
  type FocusState,
8
13
  handleListDetailKey,
9
14
  } from "../listDetailKeys.ts";
15
+ import { useDeleteConfirm } from "../useDeleteConfirm.ts";
10
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
11
18
  import { Scrollbar } from "./Scrollbar.tsx";
12
19
 
13
20
  interface WorkerPanelProps {
@@ -161,6 +168,21 @@ export const WorkerPanel = memo(function WorkerPanel({
161
168
  const itemCountRef = useLatestRef(workers.length);
162
169
  const maxLogScrollRef = useLatestRef(maxLogScroll);
163
170
  const focusRef = useLatestRef(focus);
171
+ const viewModeRef = useLatestRef(viewMode);
172
+ const selectedLogPathRef = useLatestRef(selectedLogPath);
173
+
174
+ const deleteConfirm = useDeleteConfirm(() => {
175
+ const path = selectedLogPathRef.current;
176
+ if (!path) return;
177
+ deleteWorkerLog(projectDir, path)
178
+ .catch(() => {})
179
+ .finally(() => {
180
+ setLogContent("");
181
+ setLogSize(0);
182
+ setLogTruncated(false);
183
+ setLogScroll(0);
184
+ });
185
+ });
164
186
 
165
187
  // The right pane scrolls with arrows when focused. Tee the log scroll into
166
188
  // the follow-state so reaching the bottom resumes follow mode (and any
@@ -181,6 +203,8 @@ export const WorkerPanel = memo(function WorkerPanel({
181
203
  (input, key) => {
182
204
  if (!isActive) return;
183
205
 
206
+ if (input !== "d") deleteConfirm.cancel();
207
+
184
208
  // `l` toggles between detail (worker info) and log (tail) view in the
185
209
  // right pane.
186
210
  if (input === "l") {
@@ -206,6 +230,14 @@ export const WorkerPanel = memo(function WorkerPanel({
206
230
  setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
207
231
  return;
208
232
  }
233
+
234
+ if (input === "d") {
235
+ if (viewModeRef.current !== "log") return;
236
+ const path = selectedLogPathRef.current;
237
+ if (!path) return;
238
+ deleteConfirm.pressDelete(`worker log: ${basename(path)}`);
239
+ return;
240
+ }
209
241
  },
210
242
  { isActive },
211
243
  );
@@ -225,10 +257,14 @@ export const WorkerPanel = memo(function WorkerPanel({
225
257
  {focus === "detail"
226
258
  ? " · ↑↓ scroll ⇧↑↓ page g/G top/bot ← back to list l toggle"
227
259
  : viewMode === "log"
228
- ? " · ↑↓ select → enter log l detail f filter"
260
+ ? " · ↑↓ select → enter log l detail f filter d delete log (×2)"
229
261
  : " · ↑↓ select → enter detail l view log f filter"}
230
262
  </Text>
231
263
  </Box>
264
+ <DeleteArmedBanner
265
+ armed={deleteConfirm.armed}
266
+ label={deleteConfirm.armedLabel}
267
+ />
232
268
 
233
269
  {workers.length === 0 ? (
234
270
  <Text dimColor>
@@ -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
+ }
@@ -0,0 +1,115 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Two-press delete confirmation. First press arms; second press within
5
+ * `ttlMs` confirms. Any non-`d` keystroke should call `cancel()`. The TTL
6
+ * is a safety net for idle/escape.
7
+ */
8
+
9
+ export interface DeleteConfirmController {
10
+ isArmed(): boolean;
11
+ armedLabel(): string | null;
12
+ pressDelete: (label: string) => void;
13
+ cancel: () => void;
14
+ dispose: () => void;
15
+ }
16
+
17
+ export function createDeleteConfirmController(
18
+ onConfirm: () => void,
19
+ opts: { ttlMs?: number; onChange?: () => void } = {},
20
+ ): DeleteConfirmController {
21
+ const ttlMs = opts.ttlMs ?? 3000;
22
+ let armed = false;
23
+ let label: string | null = null;
24
+ let timer: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ const clearTimer = () => {
27
+ if (timer) {
28
+ clearTimeout(timer);
29
+ timer = null;
30
+ }
31
+ };
32
+
33
+ const notify = () => {
34
+ opts.onChange?.();
35
+ };
36
+
37
+ const pressDelete = (next: string) => {
38
+ if (armed) {
39
+ clearTimer();
40
+ armed = false;
41
+ label = null;
42
+ notify();
43
+ onConfirm();
44
+ return;
45
+ }
46
+ armed = true;
47
+ label = next;
48
+ timer = setTimeout(() => {
49
+ armed = false;
50
+ label = null;
51
+ timer = null;
52
+ notify();
53
+ }, ttlMs);
54
+ notify();
55
+ };
56
+
57
+ const cancel = () => {
58
+ if (!armed && !timer) return;
59
+ clearTimer();
60
+ armed = false;
61
+ label = null;
62
+ notify();
63
+ };
64
+
65
+ const dispose = () => {
66
+ clearTimer();
67
+ };
68
+
69
+ return {
70
+ isArmed: () => armed,
71
+ armedLabel: () => label,
72
+ pressDelete,
73
+ cancel,
74
+ dispose,
75
+ };
76
+ }
77
+
78
+ export interface UseDeleteConfirmResult {
79
+ armed: boolean;
80
+ armedLabel: string | null;
81
+ pressDelete: (label: string) => void;
82
+ cancel: () => void;
83
+ }
84
+
85
+ export function useDeleteConfirm(
86
+ onConfirm: () => void,
87
+ opts: { ttlMs?: number } = {},
88
+ ): UseDeleteConfirmResult {
89
+ const [, setTick] = useState(0);
90
+
91
+ const onConfirmRef = useRef(onConfirm);
92
+ onConfirmRef.current = onConfirm;
93
+
94
+ const controllerRef = useRef<DeleteConfirmController | null>(null);
95
+ if (!controllerRef.current) {
96
+ controllerRef.current = createDeleteConfirmController(
97
+ () => onConfirmRef.current(),
98
+ { ttlMs: opts.ttlMs, onChange: () => setTick((t) => t + 1) },
99
+ );
100
+ }
101
+
102
+ useEffect(() => {
103
+ return () => {
104
+ controllerRef.current?.dispose();
105
+ };
106
+ }, []);
107
+
108
+ const c = controllerRef.current;
109
+ return {
110
+ armed: c.isArmed(),
111
+ armedLabel: c.armedLabel(),
112
+ pressDelete: c.pressDelete,
113
+ cancel: c.cancel,
114
+ };
115
+ }
@@ -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.
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat, unlink } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { getWorkersDir } from "../constants.ts";
2
+ import { join, resolve } from "node:path";
3
+ import { getWorkerLogsDir, getWorkersDir } from "../constants.ts";
4
4
  import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
5
5
 
6
6
  export const WORKER_MODES = ["persist", "once"] as const;
@@ -217,6 +217,28 @@ export async function deleteWorker(
217
217
  }
218
218
  }
219
219
 
220
+ /**
221
+ * Delete a worker's on-disk log file. Refuses to touch anything outside
222
+ * `<projectDir>/logs/`. ENOENT is treated as success (idempotent).
223
+ */
224
+ export async function deleteWorkerLog(
225
+ projectDir: string,
226
+ logPath: string,
227
+ ): Promise<boolean> {
228
+ const logsDir = resolve(getWorkerLogsDir(projectDir));
229
+ const target = resolve(logPath);
230
+ if (target !== logsDir && !target.startsWith(`${logsDir}/`)) {
231
+ throw new Error(`refusing to delete log outside ${logsDir}: ${logPath}`);
232
+ }
233
+ try {
234
+ await unlink(target);
235
+ return true;
236
+ } catch (err) {
237
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
238
+ throw err;
239
+ }
240
+ }
241
+
220
242
  async function listWorkerIds(projectDir: string): Promise<string[]> {
221
243
  const dir = getWorkersDir(projectDir);
222
244
  try {