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 +2 -1
- package/package.json +1 -1
- package/src/chat/agent.ts +5 -1
- package/src/commands/skill.ts +21 -0
- package/src/constants.ts +7 -4
- 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/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
|
|
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
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 \`
|
|
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
|
|
package/src/commands/skill.ts
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
return join(projectDir, BOTHOLOMEW_DIR,
|
|
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 {
|
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);
|
package/src/init/templates.ts
CHANGED
|
@@ -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 = `---
|
package/src/skills/commands.ts
CHANGED
|
@@ -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.
|
|
75
|
-
ctx.queueUserMessage(rendered);
|
|
100
|
+
ctx.queueUserMessage(rendered, { display: input });
|
|
76
101
|
return true;
|
|
77
102
|
}
|
|
78
103
|
|
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
|
});
|
package/src/worker/spawn.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
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
|
|
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 = [
|
|
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
|
}
|