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,130 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import { decideAndRequeue } from "../approvals/decide.ts";
4
+ import {
5
+ APPROVAL_STATUSES,
6
+ type Approval,
7
+ type ApprovalStatus,
8
+ } from "../approvals/schema.ts";
9
+ import { getApproval, listApprovals } from "../approvals/store.ts";
10
+ import { logger } from "../utils/logger.ts";
11
+
12
+ function statusColor(status: ApprovalStatus): string {
13
+ switch (status) {
14
+ case "approved":
15
+ return ansis.green(status);
16
+ case "denied":
17
+ return ansis.red(status);
18
+ case "pending":
19
+ return ansis.yellow(status);
20
+ }
21
+ }
22
+
23
+ function printApproval(a: Approval) {
24
+ console.log(
25
+ `${ansis.bold(a.id.slice(0, 8))} ${statusColor(a.status).padEnd(18)} ${ansis.cyan(`${a.server}/${a.tool}`)}`,
26
+ );
27
+ console.log(` args: ${a.args}`);
28
+ if (a.task_id) console.log(` task: ${a.task_id}`);
29
+ if (a.reason) console.log(` reason: ${a.reason}`);
30
+ console.log(` created: ${a.created_at}`);
31
+ if (a.decided_at) {
32
+ console.log(` decided: ${a.decided_at} by ${a.decided_by ?? "?"}`);
33
+ }
34
+ }
35
+
36
+ export function registerApprovalCommand(program: Command) {
37
+ const approval = program
38
+ .command("approval")
39
+ .description("Review and decide pending mcpx tool-call approvals");
40
+
41
+ approval
42
+ .command("list")
43
+ .description("List approval requests (newest first)")
44
+ .option(
45
+ "-s, --status <status>",
46
+ `filter by status (${APPROVAL_STATUSES.join("|")})`,
47
+ )
48
+ .option("-l, --limit <n>", "max number of approvals", Number.parseInt)
49
+ .option("-o, --offset <n>", "skip first N approvals", Number.parseInt)
50
+ .action(
51
+ async (opts: {
52
+ status?: ApprovalStatus;
53
+ limit?: number;
54
+ offset?: number;
55
+ }) => {
56
+ if (opts.status && !APPROVAL_STATUSES.includes(opts.status)) {
57
+ logger.error(
58
+ `Unknown status: ${opts.status}. Use one of: ${APPROVAL_STATUSES.join(", ")}`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+ const dir = program.opts().dir;
63
+ const approvals = await listApprovals(dir, {
64
+ status: opts.status,
65
+ limit: opts.limit,
66
+ offset: opts.offset,
67
+ });
68
+ if (approvals.length === 0) {
69
+ logger.dim("No approvals found.");
70
+ return;
71
+ }
72
+ for (const a of approvals) {
73
+ printApproval(a);
74
+ console.log("");
75
+ }
76
+ console.log(ansis.dim(`${approvals.length} approval(s)`));
77
+ },
78
+ );
79
+
80
+ approval
81
+ .command("view <id>")
82
+ .description("Show a single approval request")
83
+ .action(async (id: string) => {
84
+ const dir = program.opts().dir;
85
+ const a = await getApproval(dir, id);
86
+ if (!a) {
87
+ logger.error(`No approval found with id ${id}.`);
88
+ process.exit(1);
89
+ }
90
+ printApproval(a);
91
+ });
92
+
93
+ approval
94
+ .command("approve <id>")
95
+ .description("Approve a pending request and re-queue its task")
96
+ .action(async (id: string) => {
97
+ const dir = program.opts().dir;
98
+ const a = await getApproval(dir, id);
99
+ if (!a) {
100
+ logger.error(`No approval found with id ${id}.`);
101
+ process.exit(1);
102
+ }
103
+ if (a.status !== "pending") {
104
+ logger.warn(`Approval ${id} is already ${a.status}.`);
105
+ return;
106
+ }
107
+ await decideAndRequeue(dir, id, "approved", "cli");
108
+ logger.success(`Approved ${a.server}/${a.tool} (${id}).`);
109
+ if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
110
+ });
111
+
112
+ approval
113
+ .command("deny <id>")
114
+ .description("Deny a pending request and re-queue its task to recover")
115
+ .action(async (id: string) => {
116
+ const dir = program.opts().dir;
117
+ const a = await getApproval(dir, id);
118
+ if (!a) {
119
+ logger.error(`No approval found with id ${id}.`);
120
+ process.exit(1);
121
+ }
122
+ if (a.status !== "pending") {
123
+ logger.warn(`Approval ${id} is already ${a.status}.`);
124
+ return;
125
+ }
126
+ await decideAndRequeue(dir, id, "denied", "cli");
127
+ logger.success(`Denied ${a.server}/${a.tool} (${id}).`);
128
+ if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
129
+ });
130
+ }
@@ -8,8 +8,9 @@ export function registerChatCommand(program: Command) {
8
8
  "Open the interactive chat TUI\n\n" +
9
9
  " Tab navigation (Ctrl+<letter> from any tab):\n" +
10
10
  " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
11
- " Ctrl+o Tools Ctrl+e Threads Ctrl+g Help\n" +
12
- " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
11
+ " Ctrl+o Tools Ctrl+e Threads Ctrl+p Approvals\n" +
12
+ " Ctrl+n Context Ctrl+s Schedules Ctrl+g Help\n" +
13
+ " Esc Return to Chat\n\n" +
13
14
  " Refresh: Ctrl+R refreshes Context · Tasks · Threads · Schedules · Workers\n\n" +
14
15
  " Chat input:\n" +
15
16
  " Enter Send message\n" +
@@ -21,42 +22,55 @@ export function registerChatCommand(program: Command) {
21
22
  " Slash commands:\n" +
22
23
  " /help Show chat-command reference (Help tab has the full keymap)\n" +
23
24
  " /skills List available skills\n" +
25
+ " /dream Reflect on recent threads — consolidate learnings, update beliefs/goals\n" +
24
26
  " /clear End current thread and start a new one\n" +
25
27
  " /exit End the chat session",
26
28
  )
27
29
  .option("--thread-id <id>", "Resume an existing chat thread")
28
30
  .option("-p, --prompt <text>", "Start chat with an initial prompt")
29
- .action(async (opts: { threadId?: string; prompt?: string }) => {
30
- const { render } = await import("ink");
31
- const React = await import("react");
32
- const { App } = await import("../tui/App.tsx");
33
- const dir = program.opts().dir;
34
- const config = await loadConfig(dir);
35
- const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
31
+ .option(
32
+ "--unsafe",
33
+ "bypass the mcpx approval gate (allow every tool without approval)",
34
+ false,
35
+ )
36
+ .action(
37
+ async (opts: {
38
+ threadId?: string;
39
+ prompt?: string;
40
+ unsafe?: boolean;
41
+ }) => {
42
+ const { render } = await import("ink");
43
+ const React = await import("react");
44
+ const { App } = await import("../tui/App.tsx");
45
+ const dir = program.opts().dir;
46
+ const config = await loadConfig(dir);
47
+ const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
36
48
 
37
- // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
38
- // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
39
- // capture. Use "disabled" mode in capture to keep text input working;
40
- // captures that need Tab/Escape should use the `-p` prompt flag or
41
- // a /slash command typed as text instead.
42
- const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
43
- const instance = render(
44
- React.createElement(App, {
45
- projectDir: dir,
46
- threadId: opts.threadId,
47
- initialPrompt: opts.prompt,
48
- idleTimeoutMs,
49
- }),
50
- {
51
- exitOnCtrlC: false,
52
- kittyKeyboard: isCapture
53
- ? { mode: "disabled" }
54
- : {
55
- mode: "enabled",
56
- flags: ["disambiguateEscapeCodes"],
57
- },
58
- },
59
- );
60
- await instance.waitUntilExit();
61
- });
49
+ // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
50
+ // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
51
+ // capture. Use "disabled" mode in capture to keep text input working;
52
+ // captures that need Tab/Escape should use the `-p` prompt flag or
53
+ // a /slash command typed as text instead.
54
+ const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
55
+ const instance = render(
56
+ React.createElement(App, {
57
+ projectDir: dir,
58
+ threadId: opts.threadId,
59
+ initialPrompt: opts.prompt,
60
+ idleTimeoutMs,
61
+ unsafe: opts.unsafe,
62
+ }),
63
+ {
64
+ exitOnCtrlC: false,
65
+ kittyKeyboard: isCapture
66
+ ? { mode: "disabled" }
67
+ : {
68
+ mode: "enabled",
69
+ flags: ["disambiguateEscapeCodes"],
70
+ },
71
+ },
72
+ );
73
+ await instance.waitUntilExit();
74
+ },
75
+ );
62
76
  }
@@ -0,0 +1,194 @@
1
+ import type { LanguageModel } from "ai";
2
+ import ansis from "ansis";
3
+ import type { Command } from "commander";
4
+ import { type ChatTurnCallbacks, runChatTurn } from "../chat/agent.ts";
5
+ import { DREAM_PROMPT_BODY } from "../chat/dream-prompt.ts";
6
+ import { requireProviderCreds } from "../chat/session.ts";
7
+ import { loadConfig } from "../config/loader.ts";
8
+ import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
9
+ import type { WithMem } from "../mem/client.ts";
10
+ import {
11
+ createThread,
12
+ endThread,
13
+ ensureThreadsDir,
14
+ logInteraction,
15
+ } from "../threads/store.ts";
16
+ import { logger } from "../utils/logger.ts";
17
+ import { utcDateString } from "../utils/v7-date.ts";
18
+
19
+ /** Gray `HH:MM:SS` stamp, matching the logger's line prefix. */
20
+ function ts(): string {
21
+ return ansis.gray(new Date().toTimeString().slice(0, 8));
22
+ }
23
+
24
+ /** Collapse a tool-input blob to a single readable line. */
25
+ function previewInput(input: string, max = 100): string {
26
+ const oneLine = input.replace(/\s+/g, " ").trim();
27
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
28
+ }
29
+
30
+ export interface DreamOptions {
31
+ /** ISO date or relative duration (`24h`, `7d`). Defaults to `dream_lookback_hours`. */
32
+ since?: string;
33
+ /** Propose edits without writing anything. */
34
+ dryRun?: boolean;
35
+ /** Test seam: inject a language model + membot accessor. */
36
+ _testModel?: LanguageModel;
37
+ _testWithMem?: WithMem;
38
+ }
39
+
40
+ const RELATIVE_RE = /^(\d+)\s*([hd])$/i;
41
+
42
+ /**
43
+ * Resolve the start of the recall window. Accepts a relative duration
44
+ * (`24h`, `7d`), an ISO date, or — when omitted — `now - lookbackHours`.
45
+ */
46
+ export function resolveSince(
47
+ since: string | undefined,
48
+ lookbackHours: number,
49
+ now: Date,
50
+ ): Date {
51
+ if (!since) {
52
+ return new Date(now.getTime() - lookbackHours * 3600_000);
53
+ }
54
+ const rel = RELATIVE_RE.exec(since.trim());
55
+ if (rel) {
56
+ const n = Number(rel[1]);
57
+ const unitHours = rel[2]?.toLowerCase() === "d" ? 24 : 1;
58
+ return new Date(now.getTime() - n * unitHours * 3600_000);
59
+ }
60
+ const parsed = new Date(since);
61
+ if (Number.isNaN(parsed.getTime())) {
62
+ throw new Error(
63
+ `Could not parse --since "${since}". Use an ISO date (2026-06-01) or a relative duration like 24h / 7d.`,
64
+ );
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ /**
70
+ * Run one reflection ("dream") pass: review recent threads, consolidate
71
+ * durable facts into the knowledge store, and (unless `--dry-run`) apply
72
+ * justified edits to the agent's prompt files. Reuses the chat agent loop and
73
+ * its tool set, so this composes the existing primitives — no new machinery.
74
+ * Returns the audit thread id.
75
+ */
76
+ export async function runDream(
77
+ projectDir: string,
78
+ opts: DreamOptions = {},
79
+ ): Promise<string> {
80
+ const config = await loadConfig(projectDir);
81
+ requireProviderCreds(config);
82
+ await ensureThreadsDir(projectDir);
83
+
84
+ const now = new Date();
85
+ const since = resolveSince(opts.since, config.dream_lookback_hours, now);
86
+
87
+ const threadId = await createThread(
88
+ projectDir,
89
+ "chat_session",
90
+ undefined,
91
+ `Dream — ${utcDateString(now)}`,
92
+ );
93
+
94
+ const windowLine = `Scope your recall to conversations from ${since.toISOString()} onward (it is now ${now.toISOString()}).`;
95
+ const dryRunLine = opts.dryRun
96
+ ? "\n\nIMPORTANT — DRY RUN: Do NOT call `prompt_edit` or any `membot` write tool. Instead, end with a report describing the reflection you would store and the exact prompt edits you would propose, then stop."
97
+ : "";
98
+ const userMessage = `${DREAM_PROMPT_BODY}\n\n${windowLine}${dryRunLine}`;
99
+
100
+ await logInteraction(projectDir, threadId, {
101
+ role: "user",
102
+ kind: "message",
103
+ content: userMessage,
104
+ });
105
+
106
+ logger.info(
107
+ `${ansis.magenta.bold("Dreaming")} — reviewing threads since ${ansis.cyan(
108
+ since.toISOString(),
109
+ )}${opts.dryRun ? ` ${ansis.yellow("(dry run)")}` : ""}`,
110
+ );
111
+
112
+ const mcpxClient = await createMcpxClient(resolveMcpxDir(projectDir, config));
113
+
114
+ // Chat callbacks don't carry tool durations, so time each call by id.
115
+ const toolStartedAt = new Map<string, number>();
116
+ let midStream = false;
117
+
118
+ const callbacks: ChatTurnCallbacks = {
119
+ onToken: (text) => {
120
+ process.stdout.write(text);
121
+ midStream = true;
122
+ },
123
+ onToolStart: (id, name, input) => {
124
+ if (midStream) {
125
+ process.stdout.write("\n");
126
+ midStream = false;
127
+ }
128
+ toolStartedAt.set(id, Date.now());
129
+ process.stdout.write(
130
+ `${ts()} ${ansis.yellow("▶")} ${ansis.bold(name)} ${ansis.dim(
131
+ previewInput(input),
132
+ )}\n`,
133
+ );
134
+ },
135
+ onToolEnd: (id, name, _output, isError) => {
136
+ const startedAt = toolStartedAt.get(id);
137
+ const elapsed = startedAt
138
+ ? ` ${ansis.dim(`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`)}`
139
+ : "";
140
+ toolStartedAt.delete(id);
141
+ const mark = isError ? ansis.red("✗") : ansis.green("✓");
142
+ const status = isError ? ` ${ansis.red("error")}` : "";
143
+ process.stdout.write(
144
+ `${ts()} ${mark} ${ansis.bold(name)}${status}${elapsed}\n`,
145
+ );
146
+ },
147
+ };
148
+
149
+ try {
150
+ await runChatTurn({
151
+ messages: [{ role: "user", content: userMessage }],
152
+ projectDir,
153
+ config,
154
+ threadId,
155
+ mcpxClient,
156
+ callbacks,
157
+ _testModel: opts._testModel,
158
+ _testWithMem: opts._testWithMem,
159
+ });
160
+ } finally {
161
+ process.stdout.write("\n");
162
+ await endThread(projectDir, threadId);
163
+ await mcpxClient?.close();
164
+ }
165
+
166
+ return threadId;
167
+ }
168
+
169
+ export function registerDreamCommand(program: Command) {
170
+ program
171
+ .command("dream")
172
+ .description(
173
+ "Reflect on recent threads: consolidate durable facts into the knowledge store and update beliefs/goals",
174
+ )
175
+ .option(
176
+ "-s, --since <when>",
177
+ "ISO date or relative duration (24h, 7d) to scope recall (default: dream_lookback_hours)",
178
+ )
179
+ .option(
180
+ "--dry-run",
181
+ "propose edits without writing prompts or the knowledge store",
182
+ false,
183
+ )
184
+ .action(async (opts: { since?: string; dryRun?: boolean }) => {
185
+ const dir = program.opts().dir;
186
+ const threadId = await runDream(dir, {
187
+ since: opts.since,
188
+ dryRun: opts.dryRun,
189
+ });
190
+ logger.success(
191
+ `Dream complete — audit with ${ansis.cyan(`botholomew thread view ${threadId}`)}`,
192
+ );
193
+ });
194
+ }
@@ -2,8 +2,14 @@ import { rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import ansis from "ansis";
4
4
  import type { Command } from "commander";
5
+ import { deleteAllApprovals } from "../approvals/store.ts";
5
6
  import { loadConfig } from "../config/loader.ts";
6
- import { SCHEDULES_DIR, TASKS_DIR, THREADS_DIR } from "../constants.ts";
7
+ import {
8
+ APPROVALS_DIR,
9
+ SCHEDULES_DIR,
10
+ TASKS_DIR,
11
+ THREADS_DIR,
12
+ } from "../constants.ts";
7
13
  import { openMembot, resolveMembotDir } from "../mem/client.ts";
8
14
  import { deleteAllSchedules } from "../schedules/store.ts";
9
15
  import { deleteAllTasks } from "../tasks/store.ts";
@@ -86,6 +92,10 @@ async function runNuke(projectDir: string, scope: NukeScope): Promise<void> {
86
92
  `Deleted ${threads} threads (${interactions} interactions) from ${THREADS_DIR}/`,
87
93
  );
88
94
  }
95
+ if (scope === "all") {
96
+ const n = await deleteAllApprovals(projectDir);
97
+ logger.success(`Deleted ${n} approval file(s) from ${APPROVALS_DIR}/`);
98
+ }
89
99
  }
90
100
 
91
101
  function registerScope(
@@ -151,6 +161,6 @@ export function registerNukeCommand(program: Command) {
151
161
  program,
152
162
  nuke,
153
163
  "all",
154
- "Erase all agent-writable data: membot store, tasks/, schedules/, threads/",
164
+ "Erase all agent-writable data: membot store, tasks/, schedules/, threads/, approvals/",
155
165
  );
156
166
  }
@@ -22,10 +22,6 @@ function formatAge(fromIso: string | null, to = new Date()): string {
22
22
  return `${Math.floor(hours / 24)}d ago`;
23
23
  }
24
24
 
25
- function padColored(colored: string, raw: string, width: number): string {
26
- return colored + " ".repeat(Math.max(0, width - raw.length));
27
- }
28
-
29
25
  function workerStatusColor(status: WorkerStatus): string {
30
26
  switch (status) {
31
27
  case "running":
@@ -6,9 +6,12 @@ import {
6
6
  getInteractionsAfter,
7
7
  getThread,
8
8
  type Interaction,
9
+ type InteractionRole,
9
10
  isThreadEnded,
10
11
  listThreads,
12
+ searchThreads,
11
13
  type Thread,
14
+ type ThreadType,
12
15
  } from "../threads/store.ts";
13
16
  import { logger } from "../utils/logger.ts";
14
17
 
@@ -39,6 +42,67 @@ export function registerThreadCommand(program: Command) {
39
42
  }
40
43
  });
41
44
 
45
+ thread
46
+ .command("search <query>")
47
+ .description("Search past conversations for a regex (or plain substring)")
48
+ .option("--ignore-case", "case-insensitive match (default true)", true)
49
+ .option("--no-ignore-case", "case-sensitive match")
50
+ .option(
51
+ "-r, --role <role>",
52
+ "filter by role (user, assistant, system, tool)",
53
+ )
54
+ .option("-t, --type <type>", "filter by type (worker_tick, chat_session)")
55
+ .option("--since <iso>", "only threads started on or after this ISO date")
56
+ .option("--until <iso>", "only threads started on or before this ISO date")
57
+ .option("-l, --limit <n>", "max hits to return", Number.parseInt)
58
+ .action(
59
+ async (
60
+ query: string,
61
+ opts: {
62
+ ignoreCase?: boolean;
63
+ role?: string;
64
+ type?: string;
65
+ since?: string;
66
+ until?: string;
67
+ limit?: number;
68
+ },
69
+ ) => {
70
+ const dir = program.opts().dir;
71
+ let regex: RegExp;
72
+ try {
73
+ regex = new RegExp(query, opts.ignoreCase === false ? "" : "i");
74
+ } catch (err) {
75
+ logger.error(
76
+ `Invalid regex: ${err instanceof Error ? err.message : String(err)}`,
77
+ );
78
+ process.exit(1);
79
+ }
80
+ const { hits, threadsScanned } = await searchThreads(dir, {
81
+ regex,
82
+ role: opts.role as InteractionRole | undefined,
83
+ type: opts.type as ThreadType | undefined,
84
+ since: opts.since ? new Date(opts.since) : undefined,
85
+ until: opts.until ? new Date(opts.until) : undefined,
86
+ maxResults: opts.limit,
87
+ });
88
+
89
+ if (hits.length === 0) {
90
+ logger.dim(`No hits in ${threadsScanned} thread(s).`);
91
+ return;
92
+ }
93
+
94
+ for (const h of hits) {
95
+ const id = ansis.dim(h.thread_id);
96
+ const seq = ansis.dim(`#${h.sequence}`);
97
+ const snippet = h.content_snippet.replace(/\n/g, " ");
98
+ console.log(` ${id} ${seq} ${roleColor(h.role)} ${snippet}`);
99
+ }
100
+ logger.dim(
101
+ `\n${hits.length} hit(s) in ${threadsScanned} thread(s). View context: botholomew thread view <id>`,
102
+ );
103
+ },
104
+ );
105
+
42
106
  thread
43
107
  .command("view <id>")
44
108
  .description("View thread details and interactions")
@@ -64,11 +64,17 @@ export function registerWorkerCommand(program: Command) {
64
64
  "run exactly this task (implies one-shot; incompatible with --persist)",
65
65
  )
66
66
  .option("--no-eval-schedules", "skip schedule evaluation this run")
67
+ .option(
68
+ "--unsafe",
69
+ "bypass the mcpx approval gate (allow every tool without approval)",
70
+ false,
71
+ )
67
72
  .action(
68
73
  async (opts: {
69
74
  persist?: boolean;
70
75
  taskId?: string;
71
76
  evalSchedules?: boolean;
77
+ unsafe?: boolean;
72
78
  }) => {
73
79
  if (opts.persist && opts.taskId) {
74
80
  logger.error("--persist and --task-id are mutually exclusive.");
@@ -81,6 +87,7 @@ export function registerWorkerCommand(program: Command) {
81
87
  mode: opts.persist ? "persist" : "once",
82
88
  taskId: opts.taskId,
83
89
  evalSchedules: opts.evalSchedules,
90
+ unsafe: opts.unsafe,
84
91
  });
85
92
  },
86
93
  );
@@ -90,18 +97,30 @@ export function registerWorkerCommand(program: Command) {
90
97
  .description("Spawn a worker as a detached background process")
91
98
  .option("--persist", "keep running, looping over the tick cycle", false)
92
99
  .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
- });
100
+ .option(
101
+ "--unsafe",
102
+ "bypass the mcpx approval gate (allow every tool without approval)",
103
+ false,
104
+ )
105
+ .action(
106
+ async (opts: {
107
+ persist?: boolean;
108
+ taskId?: string;
109
+ unsafe?: boolean;
110
+ }) => {
111
+ if (opts.persist && opts.taskId) {
112
+ logger.error("--persist and --task-id are mutually exclusive.");
113
+ process.exit(1);
114
+ }
115
+ const dir = program.opts().dir;
116
+ const { spawnWorker } = await import("../worker/spawn.ts");
117
+ await spawnWorker(dir, {
118
+ mode: opts.persist ? "persist" : "once",
119
+ taskId: opts.taskId,
120
+ unsafe: opts.unsafe,
121
+ });
122
+ },
123
+ );
105
124
 
106
125
  worker
107
126
  .command("list")
@@ -3,6 +3,7 @@ import { getConfigPath } from "../constants.ts";
3
3
  import { setLogLevel } from "../utils/logger.ts";
4
4
  import {
5
5
  type BotholomewConfig,
6
+ DEFAULT_APPROVALS,
6
7
  DEFAULT_CHUNKER_LLM,
7
8
  DEFAULT_CONFIG,
8
9
  DEFAULT_LLM,
@@ -60,6 +61,9 @@ export async function loadConfig(
60
61
  ...userConfig,
61
62
  llm: mergeLlmBlock(DEFAULT_LLM, userConfig.llm),
62
63
  chunker_llm: mergeLlmBlock(DEFAULT_CHUNKER_LLM, userConfig.chunker_llm),
64
+ // Deep-merge so a config predating the approval gate (or only overriding
65
+ // one key) still gets the safe defaults — and back-compat keeps the gate ON.
66
+ approvals: { ...DEFAULT_APPROVALS, ...(userConfig.approvals ?? {}) },
63
67
  };
64
68
 
65
69
  const config = applyEnvOverrides(merged);
@@ -101,3 +105,26 @@ export async function saveConfig(
101
105
  const configPath = getConfigPath(projectDir);
102
106
  await Bun.write(configPath, `${JSON.stringify(config, null, 2)}\n`);
103
107
  }
108
+
109
+ /**
110
+ * Append an mcpx tool pattern to `approvals.allowed_tools` on disk, preserving
111
+ * every other key in the file (a surgical merge, not a full rewrite of merged
112
+ * defaults). Used by the chat TUI's "always allow" decision. No-op if the
113
+ * pattern is already present.
114
+ */
115
+ export async function addAllowedTool(
116
+ projectDir: string,
117
+ pattern: string,
118
+ ): Promise<void> {
119
+ const configPath = getConfigPath(projectDir);
120
+ const file = Bun.file(configPath);
121
+ const raw: Record<string, unknown> = (await file.exists())
122
+ ? JSON.parse(await file.text())
123
+ : {};
124
+ if (!raw.approvals || typeof raw.approvals !== "object") raw.approvals = {};
125
+ const approvals = raw.approvals as Record<string, unknown>;
126
+ if (!Array.isArray(approvals.allowed_tools)) approvals.allowed_tools = [];
127
+ const allowed = approvals.allowed_tools as string[];
128
+ if (!allowed.includes(pattern)) allowed.push(pattern);
129
+ await Bun.write(configPath, `${JSON.stringify(raw, null, 2)}\n`);
130
+ }