botholomew 0.9.9 → 0.9.11
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 +18 -14
- package/package.json +1 -1
- package/src/chat/agent.ts +5 -1
- package/src/cli.ts +2 -0
- package/src/commands/db.ts +112 -0
- package/src/commands/skill.ts +21 -0
- package/src/constants.ts +7 -4
- package/src/db/doctor.ts +214 -0
- package/src/db/sql/17-worker_log_path.sql +3 -0
- package/src/db/workers.ts +7 -2
- package/src/init/templates.ts +2 -0
- package/src/skills/commands.ts +29 -4
- package/src/skills/parser.ts +41 -1
- package/src/tools/registry.ts +2 -0
- package/src/tools/skill/delete.ts +56 -0
- package/src/tui/App.tsx +17 -11
- package/src/tui/components/WorkerPanel.tsx +240 -6
- package/src/worker/index.ts +15 -1
- package/src/worker/log-reader.ts +35 -0
- package/src/worker/prompt.ts +10 -0
- package/src/worker/run.ts +10 -2
- package/src/worker/spawn.ts +23 -5
package/src/skills/parser.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
330
|
+
const entry = queueRef.current.shift();
|
|
331
331
|
syncQueue();
|
|
332
|
-
if (!
|
|
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:
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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>
|
|
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,
|
|
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 ?
|
|
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
|
);
|
package/src/worker/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/worker/prompt.ts
CHANGED
|
@@ -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
|
});
|