botholomew 0.9.9 → 0.9.10

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
@@ -121,7 +121,8 @@ my-project/
121
121
  skills/ # user-defined slash commands
122
122
  summarize.md
123
123
  standup.md
124
- worker.log # stdout/stderr from spawned workers
124
+ logs/ # per-worker log files (one file per spawned worker)
125
+ <worker-id>.log
125
126
  ```
126
127
 
127
128
  Everything the agent can touch is here. No surprises.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
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
@@ -24,6 +24,7 @@ import {
24
24
  buildMetaHeader,
25
25
  extractKeywords,
26
26
  loadPersistentContext,
27
+ STYLE_RULES,
27
28
  } from "../worker/prompt.ts";
28
29
 
29
30
  registerAllTools();
@@ -58,6 +59,7 @@ const CHAT_TOOL_NAMES = new Set([
58
59
  "skill_write",
59
60
  "skill_edit",
60
61
  "skill_search",
62
+ "skill_delete",
61
63
  ]);
62
64
 
63
65
  export function getChatTools() {
@@ -114,7 +116,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
114
116
  Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items live under a drive (disk / url / agent / google-docs / github / …); use \`context_list_drives\` to discover which drives have content, then \`context_tree\`, \`context_info\`, \`context_search\`, or \`context_refresh\` as needed.
115
117
  When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.
116
118
  You can update the agent's beliefs and goals files when the user asks you to.
117
- You can author and refine slash-command skills (reusable prompt templates stored in \`.botholomew/skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, and \`skill_edit\`. New or edited skills are usable as \`/<name>\` on the user's next message.
119
+ You can author and refine slash-command skills (reusable prompt templates stored in \`.botholomew/skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, \`skill_edit\`, and \`skill_delete\`. New or edited skills are usable as \`/<name>\` on the user's next message.
118
120
  Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
119
121
  `;
120
122
 
@@ -151,6 +153,8 @@ Skip step 2 only if you already called \`mcp_info\` for that exact server+tool e
151
153
  `;
152
154
  }
153
155
 
156
+ prompt += `\n${STYLE_RULES}`;
157
+
154
158
  return prompt;
155
159
  }
156
160
 
@@ -129,6 +129,27 @@ or $1, $2, etc. for positional arguments.
129
129
  await Bun.write(filePath, template);
130
130
  logger.success(`Created skill: ${filePath}`);
131
131
  });
132
+
133
+ skill
134
+ .command("delete <name>")
135
+ .description("Delete a skill file")
136
+ .action(async (name: string) => {
137
+ const dir = program.opts().dir;
138
+ const skills = await loadSkills(dir);
139
+ const s = skills.get(name.toLowerCase());
140
+
141
+ if (!s) {
142
+ logger.error(`Skill not found: ${name}`);
143
+ if (skills.size > 0) {
144
+ const available = [...skills.keys()].sort().join(", ");
145
+ console.error(ansis.dim(`Available: ${available}`));
146
+ }
147
+ process.exit(1);
148
+ }
149
+
150
+ await Bun.file(s.filePath).delete();
151
+ logger.success(`Deleted skill: ${s.filePath}`);
152
+ });
132
153
  }
133
154
 
134
155
  async function validateSingleFile(filePath: string): Promise<void> {
package/src/constants.ts CHANGED
@@ -13,14 +13,13 @@ export const DEFAULTS = {
13
13
  UPDATE_CHECK_TIMEOUT_MS: 5_000,
14
14
  } as const;
15
15
  export const DB_FILENAME = "data.duckdb";
16
- export const LOG_FILENAME = "worker.log";
16
+ export const LOGS_DIR = "logs";
17
17
  export const CONFIG_FILENAME = "config.json";
18
18
  export const MCPX_DIR = "mcpx";
19
19
  export const SKILLS_DIR = "skills";
20
20
  export const MCPX_SERVERS_FILENAME = "servers.json";
21
21
  export const EMBEDDING_DIMENSION = 1536;
22
22
  export const EMBEDDING_MODEL = "text-embedding-3-small";
23
- export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
24
23
 
25
24
  export function getBotholomewDir(projectDir: string): string {
26
25
  return join(projectDir, BOTHOLOMEW_DIR);
@@ -30,8 +29,12 @@ export function getDbPath(projectDir: string): string {
30
29
  return join(projectDir, BOTHOLOMEW_DIR, DB_FILENAME);
31
30
  }
32
31
 
33
- export function getLogPath(projectDir: string): string {
34
- return join(projectDir, BOTHOLOMEW_DIR, LOG_FILENAME);
32
+ export function getWorkerLogsDir(projectDir: string): string {
33
+ return join(projectDir, BOTHOLOMEW_DIR, LOGS_DIR);
34
+ }
35
+
36
+ export function getWorkerLogPath(projectDir: string, workerId: string): string {
37
+ return join(projectDir, BOTHOLOMEW_DIR, LOGS_DIR, `${workerId}.log`);
35
38
  }
36
39
 
37
40
  export function getConfigPath(projectDir: string): string {
@@ -0,0 +1,3 @@
1
+ -- Per-worker log file path. NULL for foreground / in-process workers that
2
+ -- write to stdout instead of a dedicated file.
3
+ ALTER TABLE workers ADD COLUMN log_path TEXT;
package/src/db/workers.ts CHANGED
@@ -14,6 +14,7 @@ export interface Worker {
14
14
  started_at: Date;
15
15
  last_heartbeat_at: Date;
16
16
  stopped_at: Date | null;
17
+ log_path: string | null;
17
18
  }
18
19
 
19
20
  interface WorkerRow {
@@ -26,6 +27,7 @@ interface WorkerRow {
26
27
  started_at: string;
27
28
  last_heartbeat_at: string;
28
29
  stopped_at: string | null;
30
+ log_path: string | null;
29
31
  }
30
32
 
31
33
  function rowToWorker(row: WorkerRow): Worker {
@@ -39,6 +41,7 @@ function rowToWorker(row: WorkerRow): Worker {
39
41
  started_at: new Date(row.started_at),
40
42
  last_heartbeat_at: new Date(row.last_heartbeat_at),
41
43
  stopped_at: row.stopped_at ? new Date(row.stopped_at) : null,
44
+ log_path: row.log_path,
42
45
  };
43
46
  }
44
47
 
@@ -50,17 +53,19 @@ export async function registerWorker(
50
53
  hostname: string;
51
54
  mode: Worker["mode"];
52
55
  taskId?: string | null;
56
+ logPath?: string | null;
53
57
  },
54
58
  ): Promise<Worker> {
55
59
  const row = await db.queryGet<WorkerRow>(
56
- `INSERT INTO workers (id, pid, hostname, mode, task_id, status)
57
- VALUES (?1, ?2, ?3, ?4, ?5, 'running')
60
+ `INSERT INTO workers (id, pid, hostname, mode, task_id, status, log_path)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6)
58
62
  RETURNING *`,
59
63
  params.id,
60
64
  params.pid,
61
65
  params.hostname,
62
66
  params.mode,
63
67
  params.taskId ?? null,
68
+ params.logPath ?? null,
64
69
  );
65
70
  if (!row) throw new Error("INSERT did not return a row");
66
71
  return rowToWorker(row);
@@ -8,6 +8,8 @@ agent-modification: false
8
8
  You are Botholomew, an AI agent for knowledge work, personified by a wise owl. You help humans manage information, research topics, organize knowledge, and complete intellectual tasks.
9
9
 
10
10
  You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
11
+
12
+ You are direct: lead with the answer, skip preambles, disagree when you have reason to, and never flatter.
11
13
  `;
12
14
 
13
15
  export const BELIEFS_MD = `---
@@ -1,5 +1,5 @@
1
1
  import type { SkillDefinition } from "./parser.ts";
2
- import { renderSkill } from "./parser.ts";
2
+ import { renderSkill, validateSkillArgs } from "./parser.ts";
3
3
 
4
4
  export interface SlashCommand {
5
5
  name: string;
@@ -14,14 +14,32 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
14
14
  { name: "exit", description: "End the chat session" },
15
15
  ];
16
16
 
17
+ export interface QueueUserMessageOptions {
18
+ display?: string;
19
+ }
20
+
17
21
  export interface SlashCommandContext {
18
22
  skills: Map<string, SkillDefinition>;
19
23
  addSystemMessage: (content: string) => void;
20
- queueUserMessage: (content: string) => void;
24
+ queueUserMessage: (content: string, opts?: QueueUserMessageOptions) => void;
21
25
  exit: () => void;
22
26
  clearChat?: () => void;
23
27
  }
24
28
 
29
+ export function formatSkillUsage(skill: SkillDefinition): string {
30
+ const parts = [`/${skill.name}`];
31
+ for (const arg of skill.arguments) {
32
+ if (arg.required && arg.default === undefined) {
33
+ parts.push(`<${arg.name}>`);
34
+ } else if (arg.default !== undefined) {
35
+ parts.push(`[${arg.name}=${arg.default}]`);
36
+ } else {
37
+ parts.push(`[${arg.name}]`);
38
+ }
39
+ }
40
+ return parts.join(" ");
41
+ }
42
+
25
43
  /**
26
44
  * Handle a slash-command input. Returns true if the command was consumed
27
45
  * (recognized or errored), false if it should fall through.
@@ -70,9 +88,16 @@ export function handleSlashCommand(
70
88
  // Skill dispatch
71
89
  const skill = ctx.skills.get(name);
72
90
  if (skill) {
91
+ const { missing } = validateSkillArgs(skill, rawArgs);
92
+ if (missing.length > 0) {
93
+ ctx.addSystemMessage(
94
+ `/${skill.name}: missing required argument(s): ${missing.join(", ")}\n` +
95
+ `Usage: ${formatSkillUsage(skill)}`,
96
+ );
97
+ return true;
98
+ }
73
99
  const rendered = renderSkill(skill, rawArgs);
74
- ctx.addSystemMessage(`Running skill: ${skill.name}`);
75
- ctx.queueUserMessage(rendered);
100
+ ctx.queueUserMessage(rendered, { display: input });
76
101
  return true;
77
102
  }
78
103
 
@@ -55,7 +55,7 @@ export function parseSkillFile(raw: string, filePath: string): SkillDefinition {
55
55
  * Split a raw argument string into positional tokens,
56
56
  * respecting double-quoted strings.
57
57
  */
58
- function tokenize(raw: string): string[] {
58
+ export function tokenize(raw: string): string[] {
59
59
  const tokens: string[] = [];
60
60
  let current = "";
61
61
  let inQuote = false;
@@ -77,10 +77,30 @@ function tokenize(raw: string): string[] {
77
77
  return tokens;
78
78
  }
79
79
 
80
+ function escapeRegex(s: string): string {
81
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ }
83
+
80
84
  export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
81
85
  const tokens = tokenize(rawArgs);
82
86
  let result = skill.body;
83
87
 
88
+ // Replace $<argName> placeholders first, longest names first so a `$start`
89
+ // arg can't truncate `$start_date`. Word-boundary tail prevents `$end`
90
+ // from clipping `$endpoint`.
91
+ const namedArgs = skill.arguments
92
+ .map((argDef, i) => ({
93
+ name: argDef.name,
94
+ value: tokens[i] ?? argDef.default ?? "",
95
+ }))
96
+ .filter((a) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(a.name))
97
+ .sort((a, b) => b.name.length - a.name.length);
98
+
99
+ for (const { name, value } of namedArgs) {
100
+ const re = new RegExp(`\\$${escapeRegex(name)}(?![A-Za-z0-9_])`, "g");
101
+ result = result.replace(re, value);
102
+ }
103
+
84
104
  result = result.replaceAll("$ARGUMENTS", rawArgs);
85
105
 
86
106
  // Replace $1-$9 with positional args or defaults
@@ -93,3 +113,23 @@ export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
93
113
 
94
114
  return result;
95
115
  }
116
+
117
+ /**
118
+ * Identify required arguments that have neither a positional token
119
+ * nor a declared default. Used by the TUI to reject incomplete
120
+ * slash-command invocations before sending to the LLM.
121
+ */
122
+ export function validateSkillArgs(
123
+ skill: SkillDefinition,
124
+ rawArgs: string,
125
+ ): { missing: string[] } {
126
+ const tokens = tokenize(rawArgs);
127
+ const missing: string[] = [];
128
+ skill.arguments.forEach((argDef, i) => {
129
+ if (!argDef.required) return;
130
+ const hasToken = tokens[i] !== undefined;
131
+ const hasDefault = argDef.default !== undefined;
132
+ if (!hasToken && !hasDefault) missing.push(argDef.name);
133
+ });
134
+ return { missing };
135
+ }
@@ -33,6 +33,7 @@ import { listSchedulesTool } from "./schedule/list.ts";
33
33
  import { searchGrepTool } from "./search/grep.ts";
34
34
  import { searchSemanticTool } from "./search/semantic.ts";
35
35
  // Skill tools
36
+ import { skillDeleteTool } from "./skill/delete.ts";
36
37
  import { skillEditTool } from "./skill/edit.ts";
37
38
  import { skillListTool } from "./skill/list.ts";
38
39
  import { skillReadTool } from "./skill/read.ts";
@@ -102,6 +103,7 @@ export function registerAllTools(): void {
102
103
  registerTool(skillWriteTool);
103
104
  registerTool(skillEditTool);
104
105
  registerTool(skillSearchTool);
106
+ registerTool(skillDeleteTool);
105
107
 
106
108
  // Thread
107
109
  registerTool(listThreadsTool);
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ name: z.string().describe("Skill name (case-insensitive)"),
7
+ });
8
+
9
+ const outputSchema = z.object({
10
+ name: z.string().nullable(),
11
+ path: z.string().nullable(),
12
+ deleted: z.boolean(),
13
+ is_error: z.boolean(),
14
+ error_type: z.string().optional(),
15
+ message: z.string().optional(),
16
+ next_action_hint: z.string().optional(),
17
+ });
18
+
19
+ export const skillDeleteTool = {
20
+ name: "skill_delete",
21
+ description:
22
+ "[[ bash equivalent command: rm ]] Delete a skill file (user-defined slash command) by name. The file is removed from .botholomew/skills/. Returns a not_found error with the list of available names when the skill doesn't exist.",
23
+ group: "skill",
24
+ inputSchema,
25
+ outputSchema,
26
+ execute: async (input, ctx) => {
27
+ const skills = await loadSkills(ctx.projectDir);
28
+ const skill = skills.get(input.name.toLowerCase());
29
+
30
+ if (!skill) {
31
+ const available = [...skills.keys()].sort();
32
+ const hint =
33
+ available.length > 0
34
+ ? `Available: ${available.join(", ")}. Use skill_list to browse.`
35
+ : "No skills exist yet. Use skill_write to create one.";
36
+ return {
37
+ name: input.name,
38
+ path: null,
39
+ deleted: false,
40
+ is_error: true,
41
+ error_type: "not_found",
42
+ message: `Skill not found: ${input.name}`,
43
+ next_action_hint: hint,
44
+ };
45
+ }
46
+
47
+ await Bun.file(skill.filePath).delete();
48
+
49
+ return {
50
+ name: skill.name,
51
+ path: skill.filePath,
52
+ deleted: true,
53
+ is_error: false,
54
+ };
55
+ },
56
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
package/src/tui/App.tsx CHANGED
@@ -145,13 +145,13 @@ export function App({
145
145
  const [activeTab, setActiveTab] = useState<TabId>(1);
146
146
  const [workerRunning, setWorkerRunning] = useState(false);
147
147
  const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
148
- const queueRef = useRef<string[]>([]);
148
+ const queueRef = useRef<Array<{ display: string; content: string }>>([]);
149
149
  const processingRef = useRef(false);
150
150
  const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
151
151
  const [selectedQueueIndex, setSelectedQueueIndex] = useState(0);
152
152
 
153
153
  const syncQueue = useCallback(() => {
154
- const snapshot = [...queueRef.current];
154
+ const snapshot = queueRef.current.map((e) => e.display);
155
155
  setQueuedMessages(snapshot);
156
156
  setSelectedQueueIndex((prev) =>
157
157
  snapshot.length === 0 ? 0 : Math.min(prev, snapshot.length - 1),
@@ -297,7 +297,7 @@ export function App({
297
297
  );
298
298
  syncQueue();
299
299
  if (msg) {
300
- setInputValue(msg);
300
+ setInputValue(msg.display);
301
301
  }
302
302
  return;
303
303
  }
@@ -327,9 +327,9 @@ export function App({
327
327
  processingRef.current = true;
328
328
 
329
329
  while (queueRef.current.length > 0) {
330
- const trimmed = queueRef.current.shift();
330
+ const entry = queueRef.current.shift();
331
331
  syncQueue();
332
- if (!trimmed) break;
332
+ if (!entry) break;
333
333
  setIsLoading(true);
334
334
  setStreamingText("");
335
335
  setActiveToolCalls([]);
@@ -338,7 +338,7 @@ export function App({
338
338
  const userMsg: ChatMessage = {
339
339
  id: msgId(),
340
340
  role: "user",
341
- content: trimmed,
341
+ content: entry.display,
342
342
  timestamp: new Date(),
343
343
  };
344
344
  setMessages((prev) => [...prev, userMsg]);
@@ -366,7 +366,7 @@ export function App({
366
366
 
367
367
  let lastStreamFlush = 0;
368
368
  try {
369
- await sendMessage(sessionRef.current, trimmed, {
369
+ await sendMessage(sessionRef.current, entry.content, {
370
370
  onToken: (token) => {
371
371
  currentText += token;
372
372
  const now = Date.now();
@@ -432,7 +432,10 @@ export function App({
432
432
  useEffect(() => {
433
433
  if (ready && initialPrompt && !initialPromptSent.current) {
434
434
  initialPromptSent.current = true;
435
- queueRef.current.push(initialPrompt);
435
+ queueRef.current.push({
436
+ display: initialPrompt,
437
+ content: initialPrompt,
438
+ });
436
439
  syncQueue();
437
440
  setInputHistory((prev) => [...prev, initialPrompt]);
438
441
  processQueue();
@@ -570,9 +573,12 @@ export function App({
570
573
  };
571
574
  setMessages((prev) => [...prev, msg]);
572
575
  },
573
- queueUserMessage: (content) => {
576
+ queueUserMessage: (content, opts) => {
574
577
  setInputHistory((prev) => [...prev, trimmed]);
575
- queueRef.current.push(content);
578
+ queueRef.current.push({
579
+ display: opts?.display ?? content,
580
+ content,
581
+ });
576
582
  syncQueue();
577
583
  processQueue();
578
584
  },
@@ -618,7 +624,7 @@ export function App({
618
624
  }
619
625
 
620
626
  setInputHistory((prev) => [...prev, trimmed]);
621
- queueRef.current.push(trimmed);
627
+ queueRef.current.push({ display: trimmed, content: trimmed });
622
628
  syncQueue();
623
629
  processQueue();
624
630
  },
@@ -1,7 +1,8 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
- import { memo, useEffect, useState } from "react";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
3
  import { withDb } from "../../db/connection.ts";
4
4
  import { listWorkers, type Worker } from "../../db/workers.ts";
5
+ import { readLogTail } from "../../worker/log-reader.ts";
5
6
 
6
7
  interface WorkerPanelProps {
7
8
  dbPath: string;
@@ -15,6 +16,9 @@ const STATUS_FILTERS: readonly (Worker["status"] | null)[] = [
15
16
  "dead",
16
17
  ];
17
18
 
19
+ const PAGE_SCROLL_LINES = 10;
20
+ const LOG_POLL_MS = 1500;
21
+
18
22
  function statusColor(status: Worker["status"]): string {
19
23
  switch (status) {
20
24
  case "running":
@@ -36,6 +40,12 @@ function formatAge(from: Date, now: Date): string {
36
40
  return `${Math.floor(hours / 24)}d`;
37
41
  }
38
42
 
43
+ function formatBytes(n: number): string {
44
+ if (n < 1024) return `${n}B`;
45
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
46
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
47
+ }
48
+
39
49
  export const WorkerPanel = memo(function WorkerPanel({
40
50
  dbPath,
41
51
  isActive,
@@ -46,6 +56,12 @@ export const WorkerPanel = memo(function WorkerPanel({
46
56
  const [selectedIndex, setSelectedIndex] = useState(0);
47
57
  const [filterIdx, setFilterIdx] = useState(0);
48
58
  const [now, setNow] = useState(() => new Date());
59
+ const [viewMode, setViewMode] = useState<"detail" | "log">("detail");
60
+ const [logContent, setLogContent] = useState("");
61
+ const [logSize, setLogSize] = useState(0);
62
+ const [logTruncated, setLogTruncated] = useState(false);
63
+ const [logScroll, setLogScroll] = useState(0);
64
+ const [logFollow, setLogFollow] = useState(true);
49
65
 
50
66
  useEffect(() => {
51
67
  let mounted = true;
@@ -72,17 +88,135 @@ export const WorkerPanel = memo(function WorkerPanel({
72
88
  };
73
89
  }, [dbPath, filterIdx]);
74
90
 
91
+ const selected = workers[selectedIndex];
92
+ const selectedLogPath = selected?.log_path ?? null;
93
+
94
+ useEffect(() => {
95
+ if (viewMode !== "log" || !selectedLogPath) return;
96
+ let mounted = true;
97
+
98
+ const refresh = async () => {
99
+ try {
100
+ const tail = await readLogTail(selectedLogPath);
101
+ if (!mounted) return;
102
+ setLogContent(tail.content);
103
+ setLogSize(tail.size);
104
+ setLogTruncated(tail.truncated);
105
+ } catch {
106
+ // Ignore transient read errors; next tick will retry.
107
+ }
108
+ };
109
+
110
+ refresh();
111
+ const interval = setInterval(refresh, LOG_POLL_MS);
112
+ return () => {
113
+ mounted = false;
114
+ clearInterval(interval);
115
+ };
116
+ }, [viewMode, selectedLogPath]);
117
+
118
+ // Reset log scroll + content when the selection or view mode changes.
119
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset triggers
120
+ useEffect(() => {
121
+ setLogScroll(0);
122
+ setLogFollow(true);
123
+ setLogContent("");
124
+ setLogSize(0);
125
+ setLogTruncated(false);
126
+ }, [selected?.id, viewMode]);
127
+
128
+ const logLines = useMemo(() => {
129
+ if (logContent.length === 0) return [];
130
+ // Trim a single trailing newline so the rendered list doesn't end with a
131
+ // blank row, but preserve internal blank lines.
132
+ const trimmed = logContent.endsWith("\n")
133
+ ? logContent.slice(0, -1)
134
+ : logContent;
135
+ return trimmed.split("\n");
136
+ }, [logContent]);
137
+
138
+ const visibleRows = Math.max(4, termRows - 8);
139
+ const maxLogScroll = Math.max(0, logLines.length - visibleRows);
140
+
141
+ // When following, snap scroll to the bottom whenever new log content
142
+ // arrives. The user can break follow mode by scrolling up; pressing G or
143
+ // running off the end via j/J resumes it.
144
+ useEffect(() => {
145
+ if (viewMode === "log" && logFollow) {
146
+ setLogScroll(maxLogScroll);
147
+ }
148
+ }, [viewMode, logFollow, maxLogScroll]);
149
+
75
150
  useInput(
76
151
  (input, key) => {
77
152
  if (!isActive) return;
153
+
154
+ if (input === "l") {
155
+ setViewMode((m) => (m === "log" ? "detail" : "log"));
156
+ return;
157
+ }
158
+
78
159
  if (key.upArrow) {
160
+ if (viewMode === "log" && key.shift) {
161
+ setLogFollow(false);
162
+ setLogScroll((s) => Math.max(0, s - 1));
163
+ return;
164
+ }
79
165
  setSelectedIndex((i) => Math.max(0, i - 1));
80
166
  return;
81
167
  }
82
168
  if (key.downArrow) {
169
+ if (viewMode === "log" && key.shift) {
170
+ setLogScroll((s) => {
171
+ const next = Math.min(maxLogScroll, s + 1);
172
+ if (next >= maxLogScroll) setLogFollow(true);
173
+ return next;
174
+ });
175
+ return;
176
+ }
83
177
  setSelectedIndex((i) => Math.min(workers.length - 1, i + 1));
84
178
  return;
85
179
  }
180
+
181
+ if (viewMode === "log") {
182
+ if (input === "j") {
183
+ setLogScroll((s) => {
184
+ const next = Math.min(maxLogScroll, s + 1);
185
+ if (next >= maxLogScroll) setLogFollow(true);
186
+ return next;
187
+ });
188
+ return;
189
+ }
190
+ if (input === "k") {
191
+ setLogFollow(false);
192
+ setLogScroll((s) => Math.max(0, s - 1));
193
+ return;
194
+ }
195
+ if (input === "J") {
196
+ setLogScroll((s) => {
197
+ const next = Math.min(maxLogScroll, s + PAGE_SCROLL_LINES);
198
+ if (next >= maxLogScroll) setLogFollow(true);
199
+ return next;
200
+ });
201
+ return;
202
+ }
203
+ if (input === "K") {
204
+ setLogFollow(false);
205
+ setLogScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
206
+ return;
207
+ }
208
+ if (input === "g") {
209
+ setLogFollow(false);
210
+ setLogScroll(0);
211
+ return;
212
+ }
213
+ if (input === "G") {
214
+ setLogFollow(true);
215
+ setLogScroll(maxLogScroll);
216
+ return;
217
+ }
218
+ }
219
+
86
220
  if (input === "f") {
87
221
  setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
88
222
  return;
@@ -91,9 +225,8 @@ export const WorkerPanel = memo(function WorkerPanel({
91
225
  { isActive },
92
226
  );
93
227
 
94
- const selected = workers[selectedIndex];
95
228
  const filterLabel = STATUS_FILTERS[filterIdx] ?? "all";
96
- const visibleRows = Math.max(4, termRows - 10);
229
+ const visibleSidebarRows = Math.max(4, termRows - 10);
97
230
 
98
231
  return (
99
232
  <Box flexDirection="column" flexGrow={1} paddingX={1}>
@@ -103,7 +236,11 @@ export const WorkerPanel = memo(function WorkerPanel({
103
236
  </Text>
104
237
  <Text dimColor> · filter: </Text>
105
238
  <Text color="yellow">{filterLabel}</Text>
106
- <Text dimColor>{" · [f] cycle filter [↑↓] select"}</Text>
239
+ <Text dimColor>
240
+ {viewMode === "log"
241
+ ? " · [l] back [↑↓] select [j/k] scroll [g/G] top/bot [f] filter"
242
+ : " · [l] view log [f] cycle filter [↑↓] select"}
243
+ </Text>
107
244
  </Box>
108
245
 
109
246
  {workers.length === 0 ? (
@@ -121,7 +258,7 @@ export const WorkerPanel = memo(function WorkerPanel({
121
258
  marginRight={2}
122
259
  overflow="hidden"
123
260
  >
124
- {workers.slice(0, visibleRows).map((w, i) => {
261
+ {workers.slice(0, visibleSidebarRows).map((w, i) => {
125
262
  const active = i === selectedIndex;
126
263
  const short = w.id.slice(0, 8);
127
264
  return (
@@ -148,7 +285,21 @@ export const WorkerPanel = memo(function WorkerPanel({
148
285
  })}
149
286
  </Box>
150
287
  <Box flexDirection="column" flexGrow={1}>
151
- {selected ? <WorkerDetail worker={selected} now={now} /> : null}
288
+ {selected ? (
289
+ viewMode === "log" ? (
290
+ <WorkerLogView
291
+ worker={selected}
292
+ lines={logLines}
293
+ scroll={logScroll}
294
+ visibleRows={visibleRows}
295
+ truncated={logTruncated}
296
+ size={logSize}
297
+ follow={logFollow}
298
+ />
299
+ ) : (
300
+ <WorkerDetail worker={selected} now={now} />
301
+ )
302
+ ) : null}
152
303
  </Box>
153
304
  </Box>
154
305
  )}
@@ -201,6 +352,89 @@ function WorkerDetail({ worker, now }: { worker: Worker; now: Date }) {
201
352
  {worker.task_id}
202
353
  </Text>
203
354
  )}
355
+ {worker.log_path && (
356
+ <Text>
357
+ <Text dimColor>Log </Text>
358
+ <Text dimColor>{worker.log_path}</Text>
359
+ </Text>
360
+ )}
361
+ </Box>
362
+ </Box>
363
+ );
364
+ }
365
+
366
+ function WorkerLogView({
367
+ worker,
368
+ lines,
369
+ scroll,
370
+ visibleRows,
371
+ truncated,
372
+ size,
373
+ follow,
374
+ }: {
375
+ worker: Worker;
376
+ lines: string[];
377
+ scroll: number;
378
+ visibleRows: number;
379
+ truncated: boolean;
380
+ size: number;
381
+ follow: boolean;
382
+ }) {
383
+ if (!worker.log_path) {
384
+ return (
385
+ <Box flexDirection="column">
386
+ <Text bold color="blue">
387
+ {worker.id}
388
+ </Text>
389
+ <Box marginTop={1}>
390
+ <Text dimColor>
391
+ No log file (worker is running in foreground or was started before
392
+ per-worker logs existed).
393
+ </Text>
394
+ </Box>
395
+ </Box>
396
+ );
397
+ }
398
+
399
+ if (lines.length === 0) {
400
+ return (
401
+ <Box flexDirection="column">
402
+ <Text bold color="blue">
403
+ {worker.id}
404
+ </Text>
405
+ <Box marginTop={1}>
406
+ <Text dimColor>Log empty.</Text>
407
+ </Box>
408
+ </Box>
409
+ );
410
+ }
411
+
412
+ const visible = lines.slice(scroll, scroll + visibleRows);
413
+ const lastLine = Math.min(scroll + visibleRows, lines.length);
414
+
415
+ return (
416
+ <Box flexDirection="column" flexGrow={1}>
417
+ <Box>
418
+ <Text bold color="blue">
419
+ {worker.id.slice(0, 8)}
420
+ </Text>
421
+ <Text dimColor>
422
+ {" "}
423
+ · {formatBytes(size)}
424
+ {truncated ? " (tail only)" : ""} ·{" "}
425
+ </Text>
426
+ <Text color={follow ? "green" : "yellow"}>
427
+ {follow ? "following" : "paused"}
428
+ </Text>
429
+ <Text dimColor>
430
+ {" "}[{scroll + 1}–{lastLine} of {lines.length}]
431
+ </Text>
432
+ </Box>
433
+ <Box flexDirection="column" marginTop={1}>
434
+ {visible.map((line, i) => {
435
+ const lineNum = scroll + i;
436
+ return <Text key={lineNum}>{line || " "}</Text>;
437
+ })}
204
438
  </Box>
205
439
  </Box>
206
440
  );
@@ -24,6 +24,19 @@ export interface StartWorkerOptions {
24
24
  * When omitted, the worker claims the next eligible task from the queue.
25
25
  */
26
26
  taskId?: string;
27
+ /**
28
+ * Pre-allocated worker id from the spawn parent. When provided, the parent
29
+ * has already opened a per-worker log file at this id and we record both on
30
+ * the workers row. Foreground/in-process callers may omit this and a fresh
31
+ * id will be generated.
32
+ */
33
+ workerId?: string;
34
+ /**
35
+ * Path to the per-worker log file (set by the spawn parent when launching
36
+ * a detached worker). Stored on the workers row so the TUI can tail it.
37
+ * Null/undefined for foreground workers writing to stdout.
38
+ */
39
+ logPath?: string;
27
40
  /**
28
41
  * Whether to evaluate schedules as part of this run.
29
42
  * Defaults to `true` for one-shot workers without a taskId and for persist
@@ -86,7 +99,7 @@ export async function startWorker(
86
99
  logger.info("MCPX client initialized with external tools");
87
100
  }
88
101
 
89
- const workerId = uuidv7();
102
+ const workerId = options.workerId ?? uuidv7();
90
103
  await withDb(dbPath, (conn) =>
91
104
  registerWorker(conn, {
92
105
  id: workerId,
@@ -94,6 +107,7 @@ export async function startWorker(
94
107
  hostname: hostname(),
95
108
  mode,
96
109
  taskId: taskId ?? null,
110
+ logPath: options.logPath ?? null,
97
111
  }),
98
112
  );
99
113
 
@@ -0,0 +1,35 @@
1
+ export const DEFAULT_LOG_TAIL_BYTES = 128 * 1024;
2
+
3
+ export interface LogTail {
4
+ content: string;
5
+ truncated: boolean;
6
+ size: number;
7
+ }
8
+
9
+ /**
10
+ * Read the tail of a worker log file. Returns at most `maxBytes` from the end
11
+ * of the file; sets `truncated` when the file is larger than that.
12
+ *
13
+ * If the file doesn't exist (worker hasn't written anything yet), returns
14
+ * empty content rather than throwing — the caller renders an empty-state
15
+ * message instead of an error.
16
+ */
17
+ export async function readLogTail(
18
+ logPath: string,
19
+ maxBytes = DEFAULT_LOG_TAIL_BYTES,
20
+ ): Promise<LogTail> {
21
+ const file = Bun.file(logPath);
22
+ if (!(await file.exists())) {
23
+ return { content: "", truncated: false, size: 0 };
24
+ }
25
+ const size = file.size;
26
+ if (size === 0) {
27
+ return { content: "", truncated: false, size: 0 };
28
+ }
29
+ if (size <= maxBytes) {
30
+ return { content: await file.text(), truncated: false, size };
31
+ }
32
+ const start = size - maxBytes;
33
+ const content = await file.slice(start, size).text();
34
+ return { content, truncated: true, size };
35
+ }
@@ -13,6 +13,14 @@ const pkg = await Bun.file(
13
13
  new URL("../../package.json", import.meta.url),
14
14
  ).json();
15
15
 
16
+ export const STYLE_RULES = `## Style
17
+ - Open with the result, action, or next step. Skip preambles like "Great question", "You're absolutely right", "Let me…", "I'll go ahead and…".
18
+ - Don't flatter the user or their ideas. If a request is wrong, ambiguous, or risky, say so plainly with the reason.
19
+ - Hold your position when you have one. Don't capitulate to pushback that brings no new evidence.
20
+ - Be terse. Don't restate what you just did or are about to do — show it.
21
+ - Report failures and uncertainty directly. Don't paper over gaps with confident prose.
22
+ `;
23
+
16
24
  /**
17
25
  * Extract keyword set from free-form text: lowercase, split on whitespace,
18
26
  * keep words longer than 3 chars. Used to match `loading: contextual` files
@@ -160,5 +168,7 @@ Skip step 2 only if you already called \`mcp_info\` for that exact server+tool e
160
168
  `;
161
169
  }
162
170
 
171
+ prompt += `\n${STYLE_RULES}`;
172
+
163
173
  return prompt;
164
174
  }
package/src/worker/run.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  // Standalone entry point for a worker when spawned as a detached process.
4
- // Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]
4
+ // Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]
5
5
 
6
6
  import { startWorker } from "./index.ts";
7
7
 
8
8
  const projectDir = process.argv[2];
9
9
  if (!projectDir) {
10
10
  console.error(
11
- "Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
11
+ "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
12
12
  );
13
13
  process.exit(1);
14
14
  }
@@ -18,9 +18,17 @@ const persist = args.includes("--persist");
18
18
  const noEvalSchedules = args.includes("--no-eval-schedules");
19
19
  const taskIdArg = args.find((a) => a.startsWith("--task-id="));
20
20
  const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
21
+ const workerIdArg = args.find((a) => a.startsWith("--worker-id="));
22
+ const workerId = workerIdArg
23
+ ? workerIdArg.slice("--worker-id=".length)
24
+ : undefined;
25
+ const logPathArg = args.find((a) => a.startsWith("--log-path="));
26
+ const logPath = logPathArg ? logPathArg.slice("--log-path=".length) : undefined;
21
27
 
22
28
  await startWorker(projectDir, {
23
29
  mode: persist ? "persist" : "once",
24
30
  taskId,
31
+ workerId,
32
+ logPath,
25
33
  evalSchedules: noEvalSchedules ? false : undefined,
26
34
  });
@@ -1,5 +1,11 @@
1
+ import { mkdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
- import { getBotholomewDir, getLogPath } from "../constants.ts";
3
+ import {
4
+ getBotholomewDir,
5
+ getWorkerLogPath,
6
+ getWorkerLogsDir,
7
+ } from "../constants.ts";
8
+ import { uuidv7 } from "../db/uuid.ts";
3
9
  import { logger } from "../utils/logger.ts";
4
10
  import type { WorkerMode } from "./index.ts";
5
11
 
@@ -12,11 +18,14 @@ export interface SpawnWorkerOptions {
12
18
  * Spawn a worker as a detached background process. Unlike the old daemon
13
19
  * model, multiple workers per project are allowed and expected — this just
14
20
  * launches a new one.
21
+ *
22
+ * The parent generates the worker id and opens a per-worker log file before
23
+ * spawning so that the TUI / CLI can later tail just this worker's output.
15
24
  */
16
25
  export async function spawnWorker(
17
26
  projectDir: string,
18
27
  options: SpawnWorkerOptions = {},
19
- ): Promise<{ pid: number }> {
28
+ ): Promise<{ pid: number; workerId: string; logPath: string }> {
20
29
  const dotDir = getBotholomewDir(projectDir);
21
30
  const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
22
31
  if (!dirExists) {
@@ -24,11 +33,20 @@ export async function spawnWorker(
24
33
  process.exit(1);
25
34
  }
26
35
 
27
- const logPath = getLogPath(projectDir);
36
+ const workerId = uuidv7();
37
+ await mkdir(getWorkerLogsDir(projectDir), { recursive: true });
38
+ const logPath = getWorkerLogPath(projectDir, workerId);
28
39
  const logFile = Bun.file(logPath);
29
40
 
30
41
  const workerScript = new URL("./run.ts", import.meta.url).pathname;
31
- const args = ["bun", "run", workerScript, projectDir];
42
+ const args = [
43
+ "bun",
44
+ "run",
45
+ workerScript,
46
+ projectDir,
47
+ `--worker-id=${workerId}`,
48
+ `--log-path=${logPath}`,
49
+ ];
32
50
  if (options.mode === "persist") args.push("--persist");
33
51
  if (options.taskId) args.push(`--task-id=${options.taskId}`);
34
52
 
@@ -44,5 +62,5 @@ export async function spawnWorker(
44
62
  );
45
63
  logger.dim(` Log: ${logPath}`);
46
64
 
47
- return { pid: proc.pid ?? 0 };
65
+ return { pid: proc.pid ?? 0, workerId, logPath };
48
66
  }