botholomew 0.23.0 → 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.
- package/README.md +6 -2
- package/package.json +2 -2
- package/src/approvals/decide.ts +36 -0
- package/src/approvals/errors.ts +22 -0
- package/src/approvals/schema.ts +48 -0
- package/src/approvals/store.ts +276 -0
- package/src/chat/approval.ts +62 -0
- package/src/chat/session.ts +31 -2
- package/src/cli.ts +2 -0
- package/src/commands/approval.ts +130 -0
- package/src/commands/chat.ts +47 -34
- package/src/commands/nuke.ts +12 -2
- package/src/commands/worker.ts +31 -12
- package/src/config/loader.ts +27 -0
- package/src/config/schemas.ts +28 -0
- package/src/constants.ts +6 -0
- package/src/init/index.ts +5 -0
- package/src/mcpx/client.ts +83 -1
- package/src/tools/mcp/exec.ts +32 -0
- package/src/tools/tool.ts +7 -0
- package/src/tui/App.tsx +25 -2
- package/src/tui/components/ApprovalPanel.tsx +222 -0
- package/src/tui/components/ApprovalPrompt.tsx +68 -0
- package/src/tui/components/HelpPanel.tsx +3 -0
- package/src/tui/components/TabBar.tsx +13 -6
- package/src/tui/components/TabPanels.tsx +9 -0
- package/src/tui/hooks/useAppKeybindings.ts +9 -0
- package/src/tui/hooks/useApprovalCount.ts +32 -0
- package/src/tui/hooks/useApprovalPrompt.ts +49 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
- package/src/tui/hooks/useChatSession.ts +5 -3
- package/src/tui/keys.ts +1 -0
- package/src/worker/approval.ts +60 -0
- package/src/worker/index.ts +37 -4
- package/src/worker/llm.ts +18 -0
- package/src/worker/run.ts +3 -1
- package/src/worker/spawn.ts +3 -0
- package/src/worker/tick.ts +25 -2
|
@@ -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
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
|
package/src/worker/spawn.ts
CHANGED
|
@@ -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],
|
package/src/worker/tick.ts
CHANGED
|
@@ -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 {
|
|
157
|
-
|
|
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, {
|