botholomew 0.9.8 → 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.8",
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
 
@@ -160,6 +164,7 @@ export interface ToolEndMeta {
160
164
 
161
165
  export interface ChatTurnCallbacks {
162
166
  onToken: (text: string) => void;
167
+ onToolPreparing?: (id: string, name: string) => void;
163
168
  onToolStart: (id: string, name: string, input: string) => void;
164
169
  onToolEnd: (
165
170
  id: string,
@@ -251,6 +256,18 @@ export async function runChatTurn(input: {
251
256
  callbacks.onToken(text);
252
257
  });
253
258
 
259
+ stream.on("streamEvent", (event) => {
260
+ if (
261
+ event.type === "content_block_start" &&
262
+ event.content_block.type === "tool_use"
263
+ ) {
264
+ callbacks.onToolPreparing?.(
265
+ event.content_block.id,
266
+ event.content_block.name,
267
+ );
268
+ }
269
+ });
270
+
254
271
  stream.on("contentBlock", (block) => {
255
272
  if (block.type === "tool_use") {
256
273
  earlyReportedToolIds.add(block.id);
@@ -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
@@ -133,6 +133,10 @@ export function App({
133
133
  const [isLoading, setIsLoading] = useState(false);
134
134
  const [streamingText, setStreamingText] = useState("");
135
135
  const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
136
+ const [preparingTool, setPreparingTool] = useState<{
137
+ id: string;
138
+ name: string;
139
+ } | null>(null);
136
140
  const [ready, setReady] = useState(false);
137
141
  const skipSplash = !!(resumeThreadId || initialPrompt);
138
142
  const [splashDone, setSplashDone] = useState(skipSplash);
@@ -141,13 +145,13 @@ export function App({
141
145
  const [activeTab, setActiveTab] = useState<TabId>(1);
142
146
  const [workerRunning, setWorkerRunning] = useState(false);
143
147
  const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
144
- const queueRef = useRef<string[]>([]);
148
+ const queueRef = useRef<Array<{ display: string; content: string }>>([]);
145
149
  const processingRef = useRef(false);
146
150
  const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
147
151
  const [selectedQueueIndex, setSelectedQueueIndex] = useState(0);
148
152
 
149
153
  const syncQueue = useCallback(() => {
150
- const snapshot = [...queueRef.current];
154
+ const snapshot = queueRef.current.map((e) => e.display);
151
155
  setQueuedMessages(snapshot);
152
156
  setSelectedQueueIndex((prev) =>
153
157
  snapshot.length === 0 ? 0 : Math.min(prev, snapshot.length - 1),
@@ -293,7 +297,7 @@ export function App({
293
297
  );
294
298
  syncQueue();
295
299
  if (msg) {
296
- setInputValue(msg);
300
+ setInputValue(msg.display);
297
301
  }
298
302
  return;
299
303
  }
@@ -323,17 +327,18 @@ export function App({
323
327
  processingRef.current = true;
324
328
 
325
329
  while (queueRef.current.length > 0) {
326
- const trimmed = queueRef.current.shift();
330
+ const entry = queueRef.current.shift();
327
331
  syncQueue();
328
- if (!trimmed) break;
332
+ if (!entry) break;
329
333
  setIsLoading(true);
330
334
  setStreamingText("");
331
335
  setActiveToolCalls([]);
336
+ setPreparingTool(null);
332
337
 
333
338
  const userMsg: ChatMessage = {
334
339
  id: msgId(),
335
340
  role: "user",
336
- content: trimmed,
341
+ content: entry.display,
337
342
  timestamp: new Date(),
338
343
  };
339
344
  setMessages((prev) => [...prev, userMsg]);
@@ -361,7 +366,7 @@ export function App({
361
366
 
362
367
  let lastStreamFlush = 0;
363
368
  try {
364
- await sendMessage(sessionRef.current, trimmed, {
369
+ await sendMessage(sessionRef.current, entry.content, {
365
370
  onToken: (token) => {
366
371
  currentText += token;
367
372
  const now = Date.now();
@@ -370,6 +375,9 @@ export function App({
370
375
  lastStreamFlush = now;
371
376
  }
372
377
  },
378
+ onToolPreparing: (id, name) => {
379
+ setPreparingTool({ id, name });
380
+ },
373
381
  onToolStart: (id, name, input) => {
374
382
  if (currentText) {
375
383
  finalizeSegment();
@@ -383,6 +391,7 @@ export function App({
383
391
  };
384
392
  pendingToolCalls.push(tc);
385
393
  setActiveToolCalls([...pendingToolCalls]);
394
+ setPreparingTool(null);
386
395
  },
387
396
  onToolEnd: (id, _name, output, isError, meta) => {
388
397
  const tc = pendingToolCalls.find((t) => t.id === id);
@@ -410,6 +419,7 @@ export function App({
410
419
  } finally {
411
420
  setStreamingText("");
412
421
  setActiveToolCalls([]);
422
+ setPreparingTool(null);
413
423
  }
414
424
  }
415
425
 
@@ -422,7 +432,10 @@ export function App({
422
432
  useEffect(() => {
423
433
  if (ready && initialPrompt && !initialPromptSent.current) {
424
434
  initialPromptSent.current = true;
425
- queueRef.current.push(initialPrompt);
435
+ queueRef.current.push({
436
+ display: initialPrompt,
437
+ content: initialPrompt,
438
+ });
426
439
  syncQueue();
427
440
  setInputHistory((prev) => [...prev, initialPrompt]);
428
441
  processQueue();
@@ -560,9 +573,12 @@ export function App({
560
573
  };
561
574
  setMessages((prev) => [...prev, msg]);
562
575
  },
563
- queueUserMessage: (content) => {
576
+ queueUserMessage: (content, opts) => {
564
577
  setInputHistory((prev) => [...prev, trimmed]);
565
- queueRef.current.push(content);
578
+ queueRef.current.push({
579
+ display: opts?.display ?? content,
580
+ content,
581
+ });
566
582
  syncQueue();
567
583
  processQueue();
568
584
  },
@@ -608,7 +624,7 @@ export function App({
608
624
  }
609
625
 
610
626
  setInputHistory((prev) => [...prev, trimmed]);
611
- queueRef.current.push(trimmed);
627
+ queueRef.current.push({ display: trimmed, content: trimmed });
612
628
  syncQueue();
613
629
  processQueue();
614
630
  },
@@ -700,6 +716,7 @@ export function App({
700
716
  streamingText={streamingText}
701
717
  isLoading={isLoading}
702
718
  activeToolCalls={activeToolCalls}
719
+ preparingTool={preparingTool}
703
720
  />
704
721
  </Box>
705
722
  <Box
@@ -17,6 +17,7 @@ interface MessageListProps {
17
17
  streamingText: string;
18
18
  isLoading: boolean;
19
19
  activeToolCalls: ToolCallData[];
20
+ preparingTool: { id: string; name: string } | null;
20
21
  }
21
22
 
22
23
  function formatTime(date: Date): string {
@@ -127,6 +128,7 @@ export function MessageList({
127
128
  streamingText,
128
129
  isLoading,
129
130
  activeToolCalls,
131
+ preparingTool,
130
132
  }: MessageListProps) {
131
133
  return (
132
134
  <>
@@ -160,7 +162,17 @@ export function MessageList({
160
162
  </Box>
161
163
  )}
162
164
 
165
+ {preparingTool && (
166
+ <Box marginTop={1}>
167
+ <Text color={theme.accent}>
168
+ <Spinner type="dots" />
169
+ </Text>
170
+ <Text dimColor> Preparing tool call: {preparingTool.name}...</Text>
171
+ </Box>
172
+ )}
173
+
163
174
  {isLoading &&
175
+ !preparingTool &&
164
176
  !streamingText &&
165
177
  (activeToolCalls.length === 0 ||
166
178
  activeToolCalls.every((tc) => !tc.running)) && (
@@ -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
  );
@@ -144,9 +144,22 @@ class FakeMessageStream extends EventEmitter {
144
144
  if (delay > 0) await new Promise((r) => setTimeout(r, delay));
145
145
  }
146
146
  const final = buildFinalMessage(text, this.turn.toolCalls);
147
+ let blockIndex = text ? 1 : 0;
147
148
  for (const block of final.content) {
148
149
  if ((block as { type: string }).type === "tool_use") {
149
- this.emit("contentBlock", block as ToolUseBlock);
150
+ const toolUse = block as ToolUseBlock;
151
+ this.emit("streamEvent", {
152
+ type: "content_block_start",
153
+ index: blockIndex,
154
+ content_block: {
155
+ type: "tool_use",
156
+ id: toolUse.id,
157
+ name: toolUse.name,
158
+ input: {},
159
+ },
160
+ });
161
+ this.emit("contentBlock", toolUse);
162
+ blockIndex++;
150
163
  }
151
164
  }
152
165
  this.resolveFinal(final);
@@ -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
  }