botholomew 0.7.12 → 0.8.0
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 +37 -32
- package/package.json +1 -1
- package/src/chat/agent.ts +13 -11
- package/src/cli.ts +2 -2
- package/src/commands/chat.ts +29 -44
- package/src/commands/nuke.ts +11 -8
- package/src/commands/schedule.ts +1 -1
- package/src/commands/thread.ts +2 -2
- package/src/commands/with-db.ts +1 -1
- package/src/commands/worker.ts +231 -0
- package/src/config/schemas.ts +12 -0
- package/src/constants.ts +1 -27
- package/src/db/schedules.ts +66 -0
- package/src/db/schema.ts +5 -4
- package/src/db/sql/12-workers.sql +66 -0
- package/src/db/tasks.ts +25 -1
- package/src/db/threads.ts +1 -1
- package/src/db/workers.ts +207 -0
- package/src/init/index.ts +3 -1
- package/src/tools/context/read-large-result.ts +1 -1
- package/src/tools/mcp/exec.ts +1 -1
- package/src/tools/mcp/search.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/worker/spawn.ts +50 -0
- package/src/tui/App.tsx +15 -7
- package/src/tui/components/ContextPanel.tsx +5 -1
- package/src/tui/components/HelpPanel.tsx +5 -5
- package/src/tui/components/StatusBar.tsx +22 -18
- package/src/tui/components/TabBar.tsx +3 -2
- package/src/tui/components/ThreadPanel.tsx +7 -7
- package/src/tui/components/WorkerPanel.tsx +207 -0
- package/src/utils/title.ts +1 -1
- package/src/worker/heartbeat.ts +78 -0
- package/src/worker/index.ts +200 -0
- package/src/{daemon → worker}/llm.ts +5 -5
- package/src/{daemon → worker}/prompt.ts +2 -2
- package/src/worker/run.ts +26 -0
- package/src/{daemon → worker}/schedules.ts +30 -2
- package/src/worker/spawn.ts +48 -0
- package/src/{daemon → worker}/tick.ts +93 -35
- package/src/commands/daemon.ts +0 -152
- package/src/daemon/ensure-running.ts +0 -16
- package/src/daemon/healthcheck.ts +0 -47
- package/src/daemon/index.ts +0 -106
- package/src/daemon/run.ts +0 -14
- package/src/daemon/spawn.ts +0 -38
- package/src/daemon/watchdog.ts +0 -306
- package/src/utils/pid.ts +0 -55
- package/src/utils/project-registry.ts +0 -48
- /package/src/{daemon → worker}/context.ts +0 -0
- /package/src/{daemon → worker}/fake-llm.ts +0 -0
- /package/src/{daemon → worker}/fake-mcp.ts +0 -0
- /package/src/{daemon → worker}/large-results.ts +0 -0
- /package/src/{daemon → worker}/llm-client.ts +0 -0
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
**A local
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
**A local AI agent for knowledge work.** Botholomew is an autonomous agent
|
|
12
|
+
that works its way through a task queue — reading email, summarizing
|
|
13
|
+
documents, researching topics, organizing notes, and maintaining context
|
|
14
|
+
over time — while you sleep, work, or chat with it.
|
|
15
15
|
|
|
16
16
|
Unlike coding agents, Botholomew has **no shell, no filesystem, and no network
|
|
17
17
|
tools** by default. Everything it touches lives inside a single DuckDB database
|
|
@@ -22,12 +22,12 @@ is granted deliberately, per project, through MCP servers.
|
|
|
22
22
|
|
|
23
23
|
## Why Botholomew?
|
|
24
24
|
|
|
25
|
-
- **Autonomous.**
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
- **Autonomous.** Background **workers** claim tasks, work them with Claude,
|
|
26
|
+
and log every interaction. You can spawn one-shot workers on demand, a
|
|
27
|
+
long-running `--persist` worker, or point cron at `botholomew worker run`.
|
|
28
28
|
- **Portable.** Each project is a `.botholomew/` directory — markdown +
|
|
29
29
|
DuckDB. Copy it, share it, check it in (or `.gitignore` it).
|
|
30
|
-
- **Local
|
|
30
|
+
- **Local.** All data stays on your machine. Embeddings are indexed in
|
|
31
31
|
DuckDB's native vector store with HNSW. Model calls go direct to Anthropic
|
|
32
32
|
and OpenAI.
|
|
33
33
|
- **Extensible.** External tools come from MCP servers via
|
|
@@ -40,8 +40,9 @@ is granted deliberately, per project, through MCP servers.
|
|
|
40
40
|
filesystem access of its own. Everything it can touch lives in
|
|
41
41
|
`.botholomew/` — and every external capability is something you
|
|
42
42
|
explicitly add.
|
|
43
|
-
- **
|
|
44
|
-
|
|
43
|
+
- **Concurrent.** Many workers can run at once. Each registers itself in
|
|
44
|
+
the DB and heartbeats; crashed workers get reaped and their tasks go
|
|
45
|
+
back into the queue automatically.
|
|
45
46
|
- **Self-modifying.** The agent maintains its own `beliefs.md` and
|
|
46
47
|
`goals.md` — it learns, updates its priors, and revises its goals as it
|
|
47
48
|
works.
|
|
@@ -80,13 +81,17 @@ export OPENAI_API_KEY=sk-... # used for embeddings
|
|
|
80
81
|
# 3. Queue some work
|
|
81
82
|
botholomew task add "Summarize every markdown file in ~/notes"
|
|
82
83
|
|
|
83
|
-
# 4.
|
|
84
|
-
botholomew
|
|
84
|
+
# 4. Run a worker to process the queue
|
|
85
|
+
botholomew worker run # one-shot: claim and run one task
|
|
86
|
+
botholomew worker run --persist # long-running: loop until you stop it
|
|
85
87
|
|
|
86
88
|
# 5. Or chat with the agent interactively
|
|
87
89
|
botholomew chat
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
See [docs/automation.md](docs/automation.md) for cron-based setups if you
|
|
93
|
+
want Botholomew to advance on its own.
|
|
94
|
+
|
|
90
95
|
---
|
|
91
96
|
|
|
92
97
|
## What a project looks like
|
|
@@ -103,8 +108,7 @@ my-project/
|
|
|
103
108
|
skills/ # user-defined slash commands
|
|
104
109
|
summarize.md
|
|
105
110
|
standup.md
|
|
106
|
-
|
|
107
|
-
daemon.log # rotating daemon logs
|
|
111
|
+
worker.log # stdout/stderr from spawned workers
|
|
108
112
|
```
|
|
109
113
|
|
|
110
114
|
Everything the agent can touch is here. No surprises.
|
|
@@ -118,9 +122,8 @@ Everything the agent can touch is here. No surprises.
|
|
|
118
122
|
| Command | Purpose |
|
|
119
123
|
|---|---|
|
|
120
124
|
| `botholomew init` | Create `.botholomew/` with templates and a fresh database |
|
|
121
|
-
| `botholomew
|
|
122
|
-
| `botholomew
|
|
123
|
-
| `botholomew daemon list` | List all Botholomew projects on this machine |
|
|
125
|
+
| `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task |
|
|
126
|
+
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
124
127
|
| `botholomew chat` | Interactive Ink/React TUI |
|
|
125
128
|
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
|
|
126
129
|
| `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
|
|
@@ -140,16 +143,16 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
|
|
|
140
143
|
|
|
141
144
|
```
|
|
142
145
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
143
|
-
│ Chat │ │
|
|
144
|
-
│ (Ink TUI) │ │ (tick loop) │ │
|
|
145
|
-
│ │ │ │ │
|
|
146
|
+
│ Chat │ │ Worker(s) │ │ cron / │
|
|
147
|
+
│ (Ink TUI) │ │ (tick loop) │ │ tmux │
|
|
148
|
+
│ │ │ │ │ (optional)│
|
|
146
149
|
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
|
147
150
|
│ │ │
|
|
148
|
-
│ enqueue tasks │
|
|
149
|
-
│ browse history │
|
|
150
|
-
│
|
|
151
|
-
│
|
|
152
|
-
│ │
|
|
151
|
+
│ enqueue tasks │ register + heartbeat │ fire
|
|
152
|
+
│ browse history │ claim tasks │ `worker run`
|
|
153
|
+
│ spawn_worker tool │ run LLM tool loops │ on a
|
|
154
|
+
│ invoke skills │ reap dead peers │ schedule
|
|
155
|
+
│ │ log to threads │
|
|
153
156
|
└────────────┬───────────┴────────────┬───────────┘
|
|
154
157
|
│ │
|
|
155
158
|
┌─────▼────────────────────────▼─────┐
|
|
@@ -157,7 +160,8 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
|
|
|
157
160
|
│ ┌───────────┐ ┌──────────────┐ │
|
|
158
161
|
│ │ tasks │ │ context_items│ │
|
|
159
162
|
│ │ schedules │ │ embeddings │ │
|
|
160
|
-
│ │
|
|
163
|
+
│ │ workers │ │ (HNSW) │ │
|
|
164
|
+
│ │ threads │ │ │ │
|
|
161
165
|
│ └───────────┘ └──────────────┘ │
|
|
162
166
|
└─────┬───────────────────────────────┘
|
|
163
167
|
│
|
|
@@ -173,11 +177,14 @@ See [docs/architecture.md](docs/architecture.md) for a deeper tour.
|
|
|
173
177
|
|
|
174
178
|
Topics worth understanding in detail:
|
|
175
179
|
|
|
176
|
-
- **[Architecture](docs/architecture.md)** —
|
|
177
|
-
they share a database.
|
|
180
|
+
- **[Architecture](docs/architecture.md)** — workers, chat, and how
|
|
181
|
+
they share a database. Registration, heartbeat, and reaping.
|
|
182
|
+
- **[Automation](docs/automation.md)** — cron recipes and optional
|
|
183
|
+
launchd/systemd samples for running workers on a schedule without a
|
|
184
|
+
shipped watchdog.
|
|
178
185
|
- **[The TUI](docs/tui.md)** — the `botholomew chat` Ink/React terminal UI:
|
|
179
|
-
|
|
180
|
-
visualization.
|
|
186
|
+
eight tabs, slash-command autocomplete, message queue, tool-call
|
|
187
|
+
visualization, and a live workers panel.
|
|
181
188
|
- **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
|
|
182
189
|
"files" are actually DuckDB rows, and how `context_read`/`context_write` work.
|
|
183
190
|
- **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
|
|
@@ -193,8 +200,6 @@ Topics worth understanding in detail:
|
|
|
193
200
|
with positional arguments and tab completion.
|
|
194
201
|
- **[MCPX integration](docs/mcpx.md)** — configuring external servers and
|
|
195
202
|
how MCP tools are merged into the agent's toolset.
|
|
196
|
-
- **[The watchdog](docs/watchdog.md)** — launchd plists, systemd units, and
|
|
197
|
-
multi-project service naming.
|
|
198
203
|
- **[Configuration](docs/configuration.md)** — every key in `config.json`
|
|
199
204
|
and its default.
|
|
200
205
|
- **[Doc captures](docs/captures.md)** — how the screenshots and GIFs in
|
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -6,14 +6,6 @@ import type {
|
|
|
6
6
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
7
7
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
8
8
|
import { embedSingle } from "../context/embedder.ts";
|
|
9
|
-
import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
|
|
10
|
-
import { maybeStoreResult } from "../daemon/large-results.ts";
|
|
11
|
-
import { createLlmClient } from "../daemon/llm-client.ts";
|
|
12
|
-
import {
|
|
13
|
-
buildMetaHeader,
|
|
14
|
-
extractKeywords,
|
|
15
|
-
loadPersistentContext,
|
|
16
|
-
} from "../daemon/prompt.ts";
|
|
17
9
|
import { withDb } from "../db/connection.ts";
|
|
18
10
|
import { hybridSearch } from "../db/embeddings.ts";
|
|
19
11
|
import { logInteraction } from "../db/threads.ts";
|
|
@@ -25,10 +17,18 @@ import {
|
|
|
25
17
|
toAnthropicTool,
|
|
26
18
|
} from "../tools/tool.ts";
|
|
27
19
|
import { logger } from "../utils/logger.ts";
|
|
20
|
+
import { fitToContextWindow, getMaxInputTokens } from "../worker/context.ts";
|
|
21
|
+
import { maybeStoreResult } from "../worker/large-results.ts";
|
|
22
|
+
import { createLlmClient } from "../worker/llm-client.ts";
|
|
23
|
+
import {
|
|
24
|
+
buildMetaHeader,
|
|
25
|
+
extractKeywords,
|
|
26
|
+
loadPersistentContext,
|
|
27
|
+
} from "../worker/prompt.ts";
|
|
28
28
|
|
|
29
29
|
registerAllTools();
|
|
30
30
|
|
|
31
|
-
/** Tools available in chat mode — no
|
|
31
|
+
/** Tools available in chat mode — no worker terminal tools, no destructive file tools */
|
|
32
32
|
const CHAT_TOOL_NAMES = new Set([
|
|
33
33
|
"create_task",
|
|
34
34
|
"list_tasks",
|
|
@@ -36,6 +36,7 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
36
36
|
"context_search",
|
|
37
37
|
"context_info",
|
|
38
38
|
"context_refresh",
|
|
39
|
+
"context_tree",
|
|
39
40
|
"search_grep",
|
|
40
41
|
"search_semantic",
|
|
41
42
|
"list_threads",
|
|
@@ -49,6 +50,7 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
49
50
|
"mcp_info",
|
|
50
51
|
"mcp_exec",
|
|
51
52
|
"read_large_result",
|
|
53
|
+
"spawn_worker",
|
|
52
54
|
]);
|
|
53
55
|
|
|
54
56
|
export function getChatTools() {
|
|
@@ -102,10 +104,10 @@ export async function buildChatSystemPrompt(
|
|
|
102
104
|
|
|
103
105
|
parts.push("## Instructions");
|
|
104
106
|
parts.push(
|
|
105
|
-
"You are Botholomew, an AI agent personified by a wise owl. This is your interactive chat interface. Help the user manage tasks, review results from
|
|
107
|
+
"You are Botholomew, an AI agent personified by a wise owl. This is your interactive chat interface. Help the user manage tasks, review results from background worker activity, search context, and answer questions.",
|
|
106
108
|
);
|
|
107
109
|
parts.push(
|
|
108
|
-
"You do NOT execute long-running work directly — enqueue tasks for
|
|
110
|
+
"You do NOT execute long-running work directly — enqueue tasks for a background worker instead using create_task, and spawn a worker via spawn_worker when the user wants the task run now.",
|
|
109
111
|
);
|
|
110
112
|
parts.push(
|
|
111
113
|
"Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items can be looked up by virtual path or by UUID via `context_info` and refreshed via `context_refresh`.",
|
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { program } from "commander";
|
|
|
5
5
|
import { registerChatCommand } from "./commands/chat.ts";
|
|
6
6
|
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
7
7
|
import { registerContextCommand } from "./commands/context.ts";
|
|
8
|
-
import { registerDaemonCommand } from "./commands/daemon.ts";
|
|
9
8
|
import { registerInitCommand } from "./commands/init.ts";
|
|
10
9
|
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
11
10
|
import { registerNukeCommand } from "./commands/nuke.ts";
|
|
@@ -15,6 +14,7 @@ import { registerSkillCommand } from "./commands/skill.ts";
|
|
|
15
14
|
import { registerTaskCommand } from "./commands/task.ts";
|
|
16
15
|
import { registerThreadCommand } from "./commands/thread.ts";
|
|
17
16
|
import { registerUpgradeCommand } from "./commands/upgrade.ts";
|
|
17
|
+
import { registerWorkerCommand } from "./commands/worker.ts";
|
|
18
18
|
import { maybeCheckForUpdate } from "./update/background.ts";
|
|
19
19
|
|
|
20
20
|
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
@@ -33,7 +33,7 @@ program
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
registerInitCommand(program);
|
|
36
|
-
|
|
36
|
+
registerWorkerCommand(program);
|
|
37
37
|
registerTaskCommand(program);
|
|
38
38
|
registerThreadCommand(program);
|
|
39
39
|
registerScheduleCommand(program);
|
package/src/commands/chat.ts
CHANGED
|
@@ -16,49 +16,34 @@ export function registerChatCommand(program: Command) {
|
|
|
16
16
|
)
|
|
17
17
|
.option("--thread-id <id>", "Resume an existing chat thread")
|
|
18
18
|
.option("-p, --prompt <text>", "Start chat with an initial prompt")
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
daemon?: boolean;
|
|
25
|
-
}) => {
|
|
26
|
-
const { render } = await import("ink");
|
|
27
|
-
const React = await import("react");
|
|
28
|
-
const { App } = await import("../tui/App.tsx");
|
|
29
|
-
const dir = program.opts().dir;
|
|
19
|
+
.action(async (opts: { threadId?: string; prompt?: string }) => {
|
|
20
|
+
const { render } = await import("ink");
|
|
21
|
+
const React = await import("react");
|
|
22
|
+
const { App } = await import("../tui/App.tsx");
|
|
23
|
+
const dir = program.opts().dir;
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: {
|
|
56
|
-
mode: "enabled",
|
|
57
|
-
flags: ["disambiguateEscapeCodes"],
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
);
|
|
61
|
-
await instance.waitUntilExit();
|
|
62
|
-
},
|
|
63
|
-
);
|
|
25
|
+
// VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
|
|
26
|
+
// Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
|
|
27
|
+
// capture. Use "disabled" mode in capture to keep text input working;
|
|
28
|
+
// captures that need Tab/Escape should use the `-p` prompt flag or
|
|
29
|
+
// a /slash command typed as text instead.
|
|
30
|
+
const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
|
|
31
|
+
const instance = render(
|
|
32
|
+
React.createElement(App, {
|
|
33
|
+
projectDir: dir,
|
|
34
|
+
threadId: opts.threadId,
|
|
35
|
+
initialPrompt: opts.prompt,
|
|
36
|
+
}),
|
|
37
|
+
{
|
|
38
|
+
exitOnCtrlC: false,
|
|
39
|
+
kittyKeyboard: isCapture
|
|
40
|
+
? { mode: "disabled" }
|
|
41
|
+
: {
|
|
42
|
+
mode: "enabled",
|
|
43
|
+
flags: ["disambiguateEscapeCodes"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
await instance.waitUntilExit();
|
|
48
|
+
});
|
|
64
49
|
}
|
package/src/commands/nuke.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { deleteAllDaemonState } from "../db/daemon-state.ts";
|
|
|
6
6
|
import { deleteAllSchedules } from "../db/schedules.ts";
|
|
7
7
|
import { deleteAllTasks } from "../db/tasks.ts";
|
|
8
8
|
import { deleteAllThreads } from "../db/threads.ts";
|
|
9
|
+
import { listWorkers } from "../db/workers.ts";
|
|
9
10
|
import { logger } from "../utils/logger.ts";
|
|
10
|
-
import { getDaemonStatus } from "../utils/pid.ts";
|
|
11
11
|
import { withDb } from "./with-db.ts";
|
|
12
12
|
|
|
13
13
|
type NukeScope = "context" | "tasks" | "schedules" | "threads" | "all";
|
|
@@ -49,12 +49,15 @@ function printDryRun(scope: NukeScope, counts: Record<string, number>) {
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
52
|
+
async function ensureNoRunningWorkers(conn: DbConnection): Promise<boolean> {
|
|
53
|
+
const running = await listWorkers(conn, { status: "running" });
|
|
54
|
+
if (running.length > 0) {
|
|
55
55
|
logger.error(
|
|
56
|
-
|
|
56
|
+
`${running.length} worker(s) running. Stop them first: botholomew worker stop <id>`,
|
|
57
57
|
);
|
|
58
|
+
for (const w of running) {
|
|
59
|
+
logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
|
|
60
|
+
}
|
|
58
61
|
return false;
|
|
59
62
|
}
|
|
60
63
|
return true;
|
|
@@ -101,8 +104,8 @@ function registerScope(
|
|
|
101
104
|
.description(description)
|
|
102
105
|
.option("-y, --yes", "confirm the deletion (required)")
|
|
103
106
|
.action((opts) =>
|
|
104
|
-
withDb(program, async (conn
|
|
105
|
-
if (!(await
|
|
107
|
+
withDb(program, async (conn) => {
|
|
108
|
+
if (!(await ensureNoRunningWorkers(conn))) {
|
|
106
109
|
process.exit(1);
|
|
107
110
|
}
|
|
108
111
|
const tables = TABLES_BY_SCOPE[scope];
|
|
@@ -138,7 +141,7 @@ export function registerNukeCommand(program: Command) {
|
|
|
138
141
|
program,
|
|
139
142
|
nuke,
|
|
140
143
|
"threads",
|
|
141
|
-
"Erase all threads and interactions (
|
|
144
|
+
"Erase all threads and interactions (worker + chat history)",
|
|
142
145
|
);
|
|
143
146
|
registerScope(
|
|
144
147
|
program,
|
package/src/commands/schedule.ts
CHANGED
|
@@ -134,7 +134,7 @@ export function registerScheduleCommand(program: Command) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Lazy import to avoid loading LLM deps for non-trigger commands
|
|
137
|
-
const { evaluateSchedule } = await import("../
|
|
137
|
+
const { evaluateSchedule } = await import("../worker/schedules.ts");
|
|
138
138
|
const { loadConfig } = await import("../config/loader.ts");
|
|
139
139
|
const { createTask } = await import("../db/tasks.ts");
|
|
140
140
|
const { markScheduleRun } = await import("../db/schedules.ts");
|
package/src/commands/thread.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function registerThreadCommand(program: Command) {
|
|
|
18
18
|
thread
|
|
19
19
|
.command("list")
|
|
20
20
|
.description("List threads")
|
|
21
|
-
.option("-t, --type <type>", "filter by type (
|
|
21
|
+
.option("-t, --type <type>", "filter by type (worker_tick, chat_session)")
|
|
22
22
|
.option("-l, --limit <n>", "max number of threads", Number.parseInt)
|
|
23
23
|
.option("-o, --offset <n>", "skip first N threads", Number.parseInt)
|
|
24
24
|
.action((opts) =>
|
|
@@ -156,7 +156,7 @@ export function registerThreadCommand(program: Command) {
|
|
|
156
156
|
|
|
157
157
|
function typeColor(type: Thread["type"]): string {
|
|
158
158
|
switch (type) {
|
|
159
|
-
case "
|
|
159
|
+
case "worker_tick":
|
|
160
160
|
return ansis.magenta(type);
|
|
161
161
|
case "chat_session":
|
|
162
162
|
return ansis.cyan(type);
|
package/src/commands/with-db.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { migrate } from "../db/schema.ts";
|
|
|
7
7
|
/**
|
|
8
8
|
* Open a migrated DB connection from the CLI --dir flag, run the callback,
|
|
9
9
|
* and guarantee the connection is closed afterward. Retries on lock
|
|
10
|
-
* conflicts so CLI invocations cooperate with
|
|
10
|
+
* conflicts so CLI invocations cooperate with running workers or chat.
|
|
11
11
|
*/
|
|
12
12
|
export async function withDb<T>(
|
|
13
13
|
program: Command,
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../config/loader.ts";
|
|
4
|
+
import {
|
|
5
|
+
getWorker,
|
|
6
|
+
listWorkers,
|
|
7
|
+
markWorkerDead,
|
|
8
|
+
markWorkerStopped,
|
|
9
|
+
pruneStoppedWorkers,
|
|
10
|
+
reapDeadWorkers,
|
|
11
|
+
WORKER_STATUSES,
|
|
12
|
+
type Worker,
|
|
13
|
+
} from "../db/workers.ts";
|
|
14
|
+
import { logger } from "../utils/logger.ts";
|
|
15
|
+
import { withDb } from "./with-db.ts";
|
|
16
|
+
|
|
17
|
+
function formatAge(from: Date, to = new Date()): string {
|
|
18
|
+
const secs = Math.max(0, Math.floor((to.getTime() - from.getTime()) / 1000));
|
|
19
|
+
if (secs < 60) return `${secs}s ago`;
|
|
20
|
+
const mins = Math.floor(secs / 60);
|
|
21
|
+
if (mins < 60) return `${mins}m ago`;
|
|
22
|
+
const hours = Math.floor(mins / 60);
|
|
23
|
+
if (hours < 24) return `${hours}h ago`;
|
|
24
|
+
const days = Math.floor(hours / 24);
|
|
25
|
+
return `${days}d ago`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusColor(status: Worker["status"]): string {
|
|
29
|
+
switch (status) {
|
|
30
|
+
case "running":
|
|
31
|
+
return ansis.green(status);
|
|
32
|
+
case "stopped":
|
|
33
|
+
return ansis.dim(status);
|
|
34
|
+
case "dead":
|
|
35
|
+
return ansis.red(status);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function printWorker(w: Worker) {
|
|
40
|
+
const short = w.id.slice(0, 8);
|
|
41
|
+
const lines = [
|
|
42
|
+
`${ansis.bold(short)} pid=${w.pid} mode=${w.mode} ${statusColor(w.status)} host=${w.hostname}`,
|
|
43
|
+
` started: ${w.started_at.toISOString()} (${formatAge(w.started_at)})`,
|
|
44
|
+
` heartbeat: ${w.last_heartbeat_at.toISOString()} (${formatAge(w.last_heartbeat_at)})`,
|
|
45
|
+
];
|
|
46
|
+
if (w.task_id) lines.push(` task: ${w.task_id}`);
|
|
47
|
+
if (w.stopped_at) lines.push(` stopped: ${w.stopped_at.toISOString()}`);
|
|
48
|
+
console.log(lines.join("\n"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerWorkerCommand(program: Command) {
|
|
52
|
+
const worker = program
|
|
53
|
+
.command("worker")
|
|
54
|
+
.description("Manage background workers that claim and run tasks");
|
|
55
|
+
|
|
56
|
+
worker
|
|
57
|
+
.command("run")
|
|
58
|
+
.description(
|
|
59
|
+
"Run a worker in the foreground. One-shot by default: claims one task and exits. Use --persist for a long-running tick loop.",
|
|
60
|
+
)
|
|
61
|
+
.option("--persist", "keep running, looping over the tick cycle", false)
|
|
62
|
+
.option(
|
|
63
|
+
"--task-id <id>",
|
|
64
|
+
"run exactly this task (implies one-shot; incompatible with --persist)",
|
|
65
|
+
)
|
|
66
|
+
.option("--no-eval-schedules", "skip schedule evaluation this run")
|
|
67
|
+
.action(
|
|
68
|
+
async (opts: {
|
|
69
|
+
persist?: boolean;
|
|
70
|
+
taskId?: string;
|
|
71
|
+
evalSchedules?: boolean;
|
|
72
|
+
}) => {
|
|
73
|
+
if (opts.persist && opts.taskId) {
|
|
74
|
+
logger.error("--persist and --task-id are mutually exclusive.");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const dir = program.opts().dir;
|
|
78
|
+
const { startWorker } = await import("../worker/index.ts");
|
|
79
|
+
await startWorker(dir, {
|
|
80
|
+
foreground: true,
|
|
81
|
+
mode: opts.persist ? "persist" : "once",
|
|
82
|
+
taskId: opts.taskId,
|
|
83
|
+
evalSchedules: opts.evalSchedules,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
worker
|
|
89
|
+
.command("start")
|
|
90
|
+
.description("Spawn a worker as a detached background process")
|
|
91
|
+
.option("--persist", "keep running, looping over the tick cycle", false)
|
|
92
|
+
.option("--task-id <id>", "run exactly this task (implies one-shot)")
|
|
93
|
+
.action(async (opts: { persist?: boolean; taskId?: string }) => {
|
|
94
|
+
if (opts.persist && opts.taskId) {
|
|
95
|
+
logger.error("--persist and --task-id are mutually exclusive.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
const dir = program.opts().dir;
|
|
99
|
+
const { spawnWorker } = await import("../worker/spawn.ts");
|
|
100
|
+
await spawnWorker(dir, {
|
|
101
|
+
mode: opts.persist ? "persist" : "once",
|
|
102
|
+
taskId: opts.taskId,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
worker
|
|
107
|
+
.command("list")
|
|
108
|
+
.description("List workers registered in this project's database")
|
|
109
|
+
.option(
|
|
110
|
+
"-s, --status <status>",
|
|
111
|
+
`filter by status (${WORKER_STATUSES.join("|")})`,
|
|
112
|
+
)
|
|
113
|
+
.option("-l, --limit <n>", "max number of workers", Number.parseInt)
|
|
114
|
+
.option("-o, --offset <n>", "skip first N workers", Number.parseInt)
|
|
115
|
+
.action(
|
|
116
|
+
(opts: { status?: Worker["status"]; limit?: number; offset?: number }) =>
|
|
117
|
+
withDb(program, async (conn) => {
|
|
118
|
+
if (opts.status && !WORKER_STATUSES.includes(opts.status)) {
|
|
119
|
+
logger.error(
|
|
120
|
+
`Unknown status: ${opts.status}. Use one of: ${WORKER_STATUSES.join(", ")}`,
|
|
121
|
+
);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const workers = await listWorkers(conn, {
|
|
125
|
+
status: opts.status,
|
|
126
|
+
limit: opts.limit,
|
|
127
|
+
offset: opts.offset,
|
|
128
|
+
});
|
|
129
|
+
if (workers.length === 0) {
|
|
130
|
+
logger.dim("No workers found.");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const w of workers) {
|
|
134
|
+
printWorker(w);
|
|
135
|
+
console.log("");
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
worker
|
|
141
|
+
.command("status <id>")
|
|
142
|
+
.description("Show details for a single worker")
|
|
143
|
+
.action((id: string) =>
|
|
144
|
+
withDb(program, async (conn) => {
|
|
145
|
+
const w = await getWorker(conn, id);
|
|
146
|
+
if (!w) {
|
|
147
|
+
logger.error(`No worker found with id ${id}.`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
printWorker(w);
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
worker
|
|
155
|
+
.command("stop <id>")
|
|
156
|
+
.description("SIGTERM the worker's process (graceful) and mark stopped")
|
|
157
|
+
.action((id: string) =>
|
|
158
|
+
withDb(program, async (conn) => {
|
|
159
|
+
const w = await getWorker(conn, id);
|
|
160
|
+
if (!w) {
|
|
161
|
+
logger.error(`No worker found with id ${id}.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
signalWorker(w, "SIGTERM");
|
|
165
|
+
await markWorkerStopped(conn, id);
|
|
166
|
+
logger.success(`Worker ${id} signaled (SIGTERM) and marked stopped.`);
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
worker
|
|
171
|
+
.command("kill <id>")
|
|
172
|
+
.description("SIGKILL the worker's process and mark dead")
|
|
173
|
+
.action((id: string) =>
|
|
174
|
+
withDb(program, async (conn) => {
|
|
175
|
+
const w = await getWorker(conn, id);
|
|
176
|
+
if (!w) {
|
|
177
|
+
logger.error(`No worker found with id ${id}.`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
signalWorker(w, "SIGKILL");
|
|
181
|
+
await markWorkerDead(conn, id);
|
|
182
|
+
logger.success(`Worker ${id} killed (SIGKILL) and marked dead.`);
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
worker
|
|
187
|
+
.command("reap")
|
|
188
|
+
.description(
|
|
189
|
+
"Mark stale workers dead (releasing their tasks/schedule claims) and prune cleanly-stopped workers older than the retention window",
|
|
190
|
+
)
|
|
191
|
+
.action(() =>
|
|
192
|
+
withDb(program, async (conn, dir) => {
|
|
193
|
+
const config = await loadConfig(dir);
|
|
194
|
+
const reaped = await reapDeadWorkers(
|
|
195
|
+
conn,
|
|
196
|
+
config.worker_dead_after_seconds,
|
|
197
|
+
);
|
|
198
|
+
if (reaped.length === 0) {
|
|
199
|
+
logger.dim("No stale workers to reap.");
|
|
200
|
+
} else {
|
|
201
|
+
logger.success(
|
|
202
|
+
`Reaped ${reaped.length} worker(s): ${reaped.join(", ")}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const pruned = await pruneStoppedWorkers(
|
|
206
|
+
conn,
|
|
207
|
+
config.worker_stopped_retention_seconds,
|
|
208
|
+
);
|
|
209
|
+
if (pruned.length > 0) {
|
|
210
|
+
logger.success(
|
|
211
|
+
`Pruned ${pruned.length} stopped worker(s) older than retention window.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function signalWorker(w: Worker, signal: "SIGTERM" | "SIGKILL"): void {
|
|
219
|
+
if (w.status !== "running") {
|
|
220
|
+
logger.warn(
|
|
221
|
+
`Worker ${w.id} already ${w.status}; signaling PID ${w.pid} anyway.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
process.kill(w.pid, signal);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logger.warn(
|
|
228
|
+
`Could not send ${signal} to PID ${w.pid}: ${err instanceof Error ? err.message : err}. Marking DB state only.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|