botholomew 0.22.2 → 0.24.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.
Files changed (48) hide show
  1. package/README.md +11 -3
  2. package/package.json +3 -2
  3. package/src/approvals/decide.ts +36 -0
  4. package/src/approvals/errors.ts +22 -0
  5. package/src/approvals/schema.ts +48 -0
  6. package/src/approvals/store.ts +276 -0
  7. package/src/chat/approval.ts +62 -0
  8. package/src/chat/dream-prompt.ts +20 -0
  9. package/src/chat/session.ts +32 -3
  10. package/src/cli.ts +4 -0
  11. package/src/commands/approval.ts +130 -0
  12. package/src/commands/chat.ts +48 -34
  13. package/src/commands/dream.ts +194 -0
  14. package/src/commands/nuke.ts +12 -2
  15. package/src/commands/status.ts +0 -4
  16. package/src/commands/thread.ts +64 -0
  17. package/src/commands/worker.ts +31 -12
  18. package/src/config/loader.ts +27 -0
  19. package/src/config/schemas.ts +31 -0
  20. package/src/constants.ts +6 -0
  21. package/src/init/index.ts +5 -0
  22. package/src/mcpx/client.ts +83 -1
  23. package/src/skills/commands.ts +14 -0
  24. package/src/tasks/store.ts +4 -4
  25. package/src/threads/store.ts +102 -0
  26. package/src/tools/mcp/exec.ts +32 -0
  27. package/src/tools/skill/write.ts +3 -3
  28. package/src/tools/thread/search.ts +21 -73
  29. package/src/tools/tool.ts +7 -0
  30. package/src/tui/App.tsx +25 -2
  31. package/src/tui/components/ApprovalPanel.tsx +222 -0
  32. package/src/tui/components/ApprovalPrompt.tsx +68 -0
  33. package/src/tui/components/HelpPanel.tsx +3 -0
  34. package/src/tui/components/TabBar.tsx +13 -6
  35. package/src/tui/components/TabPanels.tsx +9 -0
  36. package/src/tui/hooks/useAppKeybindings.ts +9 -0
  37. package/src/tui/hooks/useApprovalCount.ts +32 -0
  38. package/src/tui/hooks/useApprovalPrompt.ts +49 -0
  39. package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
  40. package/src/tui/hooks/useChatSession.ts +5 -3
  41. package/src/tui/keys.ts +1 -0
  42. package/src/worker/approval.ts +60 -0
  43. package/src/worker/index.ts +37 -4
  44. package/src/worker/llm.ts +18 -0
  45. package/src/worker/run.ts +3 -1
  46. package/src/worker/spawn.ts +3 -0
  47. package/src/worker/tick.ts +25 -2
  48. package/src/workers/store.ts +4 -4
@@ -0,0 +1,60 @@
1
+ import type { ToolApprovalCallback } from "@evantahler/mcpx";
2
+ import { ApprovalPendingError } from "../approvals/errors.ts";
3
+ import {
4
+ callKey,
5
+ consumeApproval,
6
+ createApproval,
7
+ findByCallKey,
8
+ } from "../approvals/store.ts";
9
+
10
+ /**
11
+ * Mutable, per-worker holder for the task/thread currently being processed.
12
+ * The worker shares one long-lived `McpxClient` across ticks, so its approval
13
+ * callback can't close over a single task — it reads the current task/thread
14
+ * from this holder, which `runClaimedTask` updates before each agent loop.
15
+ * Ticks are sequential per worker, so there's no race.
16
+ */
17
+ export interface WorkerApprovalCtx {
18
+ taskId: string | null;
19
+ threadId: string | null;
20
+ }
21
+
22
+ /**
23
+ * Build the worker's `onApprovalRequired` callback. A worker can't prompt a
24
+ * human, so it resolves a gated mcpx call against the on-disk approval queue:
25
+ * - an existing **approved** record → consume it and allow the call,
26
+ * - an existing **denied** record → return false (mcpx throws ToolApprovalDeniedError),
27
+ * - a **pending** record → throw ApprovalPendingError (task parks, stays queued),
28
+ * - **no record** → write a pending `approvals/<id>.md` and throw ApprovalPendingError.
29
+ */
30
+ export function makeWorkerApprovalCallback(
31
+ projectDir: string,
32
+ workerId: string,
33
+ ctx: WorkerApprovalCtx,
34
+ ): ToolApprovalCallback {
35
+ return async ({ server, tool, args, reason }) => {
36
+ const key = callKey(server, tool, args);
37
+ const existing = await findByCallKey(projectDir, key);
38
+ if (existing) {
39
+ if (existing.status === "approved") {
40
+ await consumeApproval(projectDir, existing.id);
41
+ return true;
42
+ }
43
+ if (existing.status === "denied") {
44
+ return false;
45
+ }
46
+ // pending — already queued, still awaiting a human decision.
47
+ throw new ApprovalPendingError(existing.id, server, tool);
48
+ }
49
+ const created = await createApproval(projectDir, {
50
+ server,
51
+ tool,
52
+ args,
53
+ reason,
54
+ task_id: ctx.taskId,
55
+ thread_id: ctx.threadId,
56
+ worker_id: workerId,
57
+ });
58
+ throw new ApprovalPendingError(created.id, server, tool);
59
+ };
60
+ }
@@ -1,10 +1,18 @@
1
1
  import { hostname } from "node:os";
2
2
  import ansis from "ansis";
3
3
  import { loadConfig } from "../config/loader.ts";
4
- import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
4
+ import {
5
+ buildApprovalPolicy,
6
+ createMcpxClient,
7
+ resolveMcpxDir,
8
+ } from "../mcpx/client.ts";
5
9
  import { logger } from "../utils/logger.ts";
6
10
  import { uuidv7 } from "../utils/uuid.ts";
7
11
  import { markWorkerStopped, registerWorker } from "../workers/store.ts";
12
+ import {
13
+ makeWorkerApprovalCallback,
14
+ type WorkerApprovalCtx,
15
+ } from "./approval.ts";
8
16
  import { startHeartbeat, startReaper } from "./heartbeat.ts";
9
17
  import type { WorkerStreamCallbacks } from "./llm.ts";
10
18
  import { runSpecificTask, tick } from "./tick.ts";
@@ -41,6 +49,11 @@ export interface StartWorkerOptions {
41
49
  * out into unrelated schedule processing).
42
50
  */
43
51
  evalSchedules?: boolean;
52
+ /**
53
+ * Bypass the mcpx approval gate for this run (allow every tool, like
54
+ * Claude Code's --dangerously-skip-permissions). Overrides `approvals.enabled`.
55
+ */
56
+ unsafe?: boolean;
44
57
  }
45
58
 
46
59
  function buildForegroundCallbacks(): WorkerStreamCallbacks {
@@ -87,12 +100,29 @@ export async function startWorker(
87
100
 
88
101
  const config = await loadConfig(projectDir);
89
102
 
90
- const mcpxClient = await createMcpxClient(resolveMcpxDir(projectDir, config));
103
+ const workerId = options.workerId ?? uuidv7();
104
+
105
+ // Approval gate wiring. The callback reads the current task/thread from a
106
+ // mutable holder that `runClaimedTask` updates before each agent loop.
107
+ const approvalCtx: WorkerApprovalCtx = { taskId: null, threadId: null };
108
+ const approvalPolicy = buildApprovalPolicy(config, {
109
+ unsafe: options.unsafe,
110
+ });
111
+ const mcpxClient = await createMcpxClient(
112
+ resolveMcpxDir(projectDir, config),
113
+ {
114
+ approvalPolicy,
115
+ onApprovalRequired: approvalPolicy
116
+ ? makeWorkerApprovalCallback(projectDir, workerId, approvalCtx)
117
+ : undefined,
118
+ },
119
+ );
91
120
  if (mcpxClient) {
92
- logger.info("MCPX client initialized with external tools");
121
+ logger.info(
122
+ `MCPX client initialized with external tools${approvalPolicy ? " (approval gate active)" : ""}`,
123
+ );
93
124
  }
94
125
 
95
- const workerId = options.workerId ?? uuidv7();
96
126
  await registerWorker(projectDir, {
97
127
  id: workerId,
98
128
  pid: process.pid,
@@ -149,6 +179,7 @@ export async function startWorker(
149
179
  taskId,
150
180
  mcpxClient,
151
181
  callbacks,
182
+ approvalCtx,
152
183
  });
153
184
  } else {
154
185
  await tick({
@@ -159,6 +190,7 @@ export async function startWorker(
159
190
  callbacks,
160
191
  tickNum: 1,
161
192
  evalSchedules,
193
+ approvalCtx,
162
194
  });
163
195
  }
164
196
  return;
@@ -179,6 +211,7 @@ export async function startWorker(
179
211
  callbacks,
180
212
  tickNum,
181
213
  evalSchedules: true,
214
+ approvalCtx,
182
215
  });
183
216
  } catch (err) {
184
217
  logger.error(`Tick failed: ${err}`);
package/src/worker/llm.ts CHANGED
@@ -115,6 +115,11 @@ export async function runAgentLoop(input: {
115
115
 
116
116
  const maxTurns = config.max_turns;
117
117
  let nudgeCount = 0;
118
+ // Set by mcp_exec (via ToolContext.onApprovalPending) when a gated call has
119
+ // no decision yet. We park the task as `waiting` after the turn so it can be
120
+ // re-queued once a human approves — robust even if the agent ignores the
121
+ // wait_task hint in the structured tool result.
122
+ let pendingApprovalId: string | null = null;
118
123
  for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
119
124
  const startTime = Date.now();
120
125
  fitToContextWindow(messages, systemPrompt, maxInputTokens);
@@ -256,6 +261,9 @@ export async function runAgentLoop(input: {
256
261
  config,
257
262
  mcpxClient: input.mcpxClient ?? null,
258
263
  workerId,
264
+ onApprovalPending: (id) => {
265
+ pendingApprovalId = id;
266
+ },
259
267
  });
260
268
  const elapsed = Date.now() - start;
261
269
  callbacks?.onToolEnd(tc.name, result.output, result.isError, elapsed);
@@ -306,6 +314,15 @@ export async function runAgentLoop(input: {
306
314
 
307
315
  messages.push({ role: "tool", content: toolResultContent });
308
316
 
317
+ // A gated mcpx call with no decision yet — park the task. It'll be
318
+ // re-queued to `pending` when a human approves/denies the request.
319
+ if (pendingApprovalId) {
320
+ return {
321
+ status: "waiting",
322
+ reason: `Awaiting human approval (${pendingApprovalId})`,
323
+ };
324
+ }
325
+
309
326
  // Touch describeModel so the import isn't flagged unused on a clean build.
310
327
  void describeModel;
311
328
  }
@@ -326,6 +343,7 @@ interface ToolCallCtx {
326
343
  config: BotholomewConfig;
327
344
  mcpxClient: McpxClient | null;
328
345
  workerId?: string;
346
+ onApprovalPending?: (approvalId: string) => void;
329
347
  }
330
348
 
331
349
  async function executeToolCall(
package/src/worker/run.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { startWorker } from "./index.ts";
10
10
 
11
11
  const USAGE =
12
- "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]";
12
+ "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules] [--unsafe]";
13
13
 
14
14
  /**
15
15
  * Parse worker args (`<projectDir> [flags]`) and start the worker. Shared by
@@ -25,6 +25,7 @@ export async function runWorkerFromArgv(args: string[]): Promise<void> {
25
25
  const flags = args.slice(1);
26
26
  const persist = flags.includes("--persist");
27
27
  const noEvalSchedules = flags.includes("--no-eval-schedules");
28
+ const unsafe = flags.includes("--unsafe");
28
29
  const taskIdArg = flags.find((a) => a.startsWith("--task-id="));
29
30
  const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
30
31
  const workerIdArg = flags.find((a) => a.startsWith("--worker-id="));
@@ -42,6 +43,7 @@ export async function runWorkerFromArgv(args: string[]): Promise<void> {
42
43
  workerId,
43
44
  logPath,
44
45
  evalSchedules: noEvalSchedules ? false : undefined,
46
+ unsafe,
45
47
  });
46
48
  }
47
49
 
@@ -11,6 +11,8 @@ import { WORKER_RUN_SENTINEL } from "./sentinel.ts";
11
11
  export interface SpawnWorkerOptions {
12
12
  mode?: WorkerMode;
13
13
  taskId?: string;
14
+ /** Propagate `--unsafe` to the detached worker (bypass the approval gate). */
15
+ unsafe?: boolean;
14
16
  }
15
17
 
16
18
  /**
@@ -67,6 +69,7 @@ export async function spawnWorker(
67
69
  args.push(`--worker-id=${workerId}`, `--log-path=${logPath}`);
68
70
  if (options.mode === "persist") args.push("--persist");
69
71
  if (options.taskId) args.push(`--task-id=${options.taskId}`);
72
+ if (options.unsafe) args.push("--unsafe");
70
73
 
71
74
  const proc = Bun.spawn(args, {
72
75
  stdio: ["ignore", logFile, logFile],
@@ -17,6 +17,7 @@ import {
17
17
  import { createThread, endThread, logInteraction } from "../threads/store.ts";
18
18
  import { logger } from "../utils/logger.ts";
19
19
  import { generateThreadTitle } from "../utils/title.ts";
20
+ import type { WorkerApprovalCtx } from "./approval.ts";
20
21
  import type { WorkerStreamCallbacks } from "./llm.ts";
21
22
  import { runAgentLoop } from "./llm.ts";
22
23
  import { buildSystemPrompt } from "./prompt.ts";
@@ -30,6 +31,8 @@ export interface TickOptions {
30
31
  callbacks?: WorkerStreamCallbacks;
31
32
  tickNum?: number;
32
33
  evalSchedules?: boolean;
34
+ /** Holder the mcpx approval callback reads; set per-task before the loop. */
35
+ approvalCtx?: WorkerApprovalCtx;
33
36
  }
34
37
 
35
38
  /**
@@ -50,6 +53,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
50
53
  callbacks,
51
54
  tickNum = 1,
52
55
  evalSchedules = true,
56
+ approvalCtx,
53
57
  } = opts;
54
58
 
55
59
  const tickStart = Date.now();
@@ -93,6 +97,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
93
97
  mcpxClient,
94
98
  callbacks,
95
99
  task,
100
+ approvalCtx,
96
101
  });
97
102
  } finally {
98
103
  await mem.close();
@@ -114,6 +119,7 @@ export async function runSpecificTask(opts: {
114
119
  taskId: string;
115
120
  mcpxClient?: McpxClient | null;
116
121
  callbacks?: WorkerStreamCallbacks;
122
+ approvalCtx?: WorkerApprovalCtx;
117
123
  }): Promise<boolean> {
118
124
  const task = await claimSpecificTask(
119
125
  opts.projectDir,
@@ -137,6 +143,7 @@ export async function runSpecificTask(opts: {
137
143
  mcpxClient: opts.mcpxClient,
138
144
  callbacks: opts.callbacks,
139
145
  task,
146
+ approvalCtx: opts.approvalCtx,
140
147
  });
141
148
  } finally {
142
149
  await mem.close();
@@ -152,9 +159,18 @@ async function runClaimedTask(opts: {
152
159
  mcpxClient?: McpxClient | null;
153
160
  callbacks?: WorkerStreamCallbacks;
154
161
  task: Task;
162
+ approvalCtx?: WorkerApprovalCtx;
155
163
  }): Promise<void> {
156
- const { projectDir, withMem, config, workerId, mcpxClient, callbacks, task } =
157
- opts;
164
+ const {
165
+ projectDir,
166
+ withMem,
167
+ config,
168
+ workerId,
169
+ mcpxClient,
170
+ callbacks,
171
+ task,
172
+ approvalCtx,
173
+ } = opts;
158
174
 
159
175
  logger.info(`Claimed task: ${task.name} (${task.id})`);
160
176
  if (!callbacks && task.description) {
@@ -169,6 +185,13 @@ async function runClaimedTask(opts: {
169
185
  `Working: ${task.name}`,
170
186
  );
171
187
 
188
+ // Point the (shared) mcpx approval callback at this task/thread so any
189
+ // approval record it writes is attributable and the task can be re-queued.
190
+ if (approvalCtx) {
191
+ approvalCtx.taskId = task.id;
192
+ approvalCtx.threadId = threadId;
193
+ }
194
+
172
195
  let systemPrompt: string;
173
196
  try {
174
197
  systemPrompt = await buildSystemPrompt(projectDir, task, config, {
@@ -87,7 +87,7 @@ export async function registerWorker(
87
87
  */
88
88
  export async function heartbeat(projectDir: string, id: string): Promise<void> {
89
89
  const worker = await readWorker(projectDir, id);
90
- if (!worker || worker.status !== "running") return;
90
+ if (worker?.status !== "running") return;
91
91
  worker.last_heartbeat_at = new Date().toISOString();
92
92
  await writeWorker(projectDir, worker);
93
93
  }
@@ -97,7 +97,7 @@ export async function markWorkerStopped(
97
97
  id: string,
98
98
  ): Promise<void> {
99
99
  const worker = await readWorker(projectDir, id);
100
- if (!worker || worker.status !== "running") return;
100
+ if (worker?.status !== "running") return;
101
101
  worker.status = "stopped";
102
102
  worker.stopped_at = new Date().toISOString();
103
103
  await writeWorker(projectDir, worker);
@@ -130,7 +130,7 @@ export async function reapDeadWorkers(
130
130
  const reaped: string[] = [];
131
131
  for (const id of ids) {
132
132
  const w = await readWorker(projectDir, id);
133
- if (!w || w.status !== "running") continue;
133
+ if (w?.status !== "running") continue;
134
134
  const heartbeatMs = Date.parse(w.last_heartbeat_at);
135
135
  if (Number.isFinite(heartbeatMs) && heartbeatMs >= cutoff) continue;
136
136
  w.status = "dead";
@@ -162,7 +162,7 @@ export async function pruneStoppedWorkers(
162
162
  const pruned: string[] = [];
163
163
  for (const id of ids) {
164
164
  const w = await readWorker(projectDir, id);
165
- if (!w || w.status !== "stopped" || !w.stopped_at) continue;
165
+ if (w?.status !== "stopped" || !w.stopped_at) continue;
166
166
  const stoppedMs = Date.parse(w.stopped_at);
167
167
  if (Number.isFinite(stoppedMs) && stoppedMs >= cutoff) continue;
168
168
  try {