botholomew 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -62,6 +62,7 @@ const CHAT_TOOL_NAMES = new Set([
62
62
  "skill_edit",
63
63
  "skill_search",
64
64
  "skill_delete",
65
+ "sleep",
65
66
  ]);
66
67
 
67
68
  export function getChatTools() {
@@ -364,6 +365,7 @@ export async function runChatTurn(input: {
364
365
  projectDir,
365
366
  config,
366
367
  mcpxClient,
368
+ shouldAbort: session ? () => session.aborted : undefined,
367
369
  });
368
370
  const durationMs = Date.now() - start;
369
371
  const stored = maybeStoreResult(toolUse.name, result.output);
@@ -411,6 +413,7 @@ interface ChatToolCallCtx {
411
413
  projectDir: string;
412
414
  config: Required<BotholomewConfig>;
413
415
  mcpxClient: McpxClient | null;
416
+ shouldAbort?: () => boolean;
414
417
  }
415
418
 
416
419
  async function executeChatToolCall(
@@ -434,10 +437,20 @@ async function executeChatToolCall(
434
437
  }
435
438
 
436
439
  try {
437
- const result = await withDb(baseCtx.dbPath, (conn) => {
438
- const ctx: ToolContext = { ...baseCtx, conn };
439
- return tool.execute(parsed.data, ctx);
440
- });
440
+ // `sleep` deliberately yields for up to an hour; opening a DuckDB
441
+ // connection for that whole window would hold the instance-level file
442
+ // lock and block any worker that also wants the DB. Run it without a
443
+ // connection — the tool doesn't touch the DB.
444
+ const runWithoutDb = tool.name === "sleep";
445
+ const result = runWithoutDb
446
+ ? await tool.execute(parsed.data, {
447
+ ...baseCtx,
448
+ conn: undefined as unknown as ToolContext["conn"],
449
+ })
450
+ : await withDb(baseCtx.dbPath, (conn) => {
451
+ const ctx: ToolContext = { ...baseCtx, conn };
452
+ return tool.execute(parsed.data, ctx);
453
+ });
441
454
  const isError =
442
455
  typeof result === "object" && result !== null && "is_error" in result
443
456
  ? (result as { is_error: boolean }).is_error
@@ -196,9 +196,10 @@ function renderTreeAnsi(
196
196
  ): string {
197
197
  const lines: string[] = [];
198
198
  const connector = isRoot ? "" : isLast ? "└── " : "├── ";
199
- const label = node.is_directory
199
+ const base = node.is_directory
200
200
  ? ansis.blue(node.name === "." ? "context/" : `${node.name}/`)
201
201
  : node.name;
202
+ const label = node.is_symlink ? `${base} ${ansis.cyan("→")}` : base;
202
203
  lines.push(`${prefix}${connector}${label}`);
203
204
  if (node.is_directory && node.children) {
204
205
  const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");
@@ -3,6 +3,7 @@ import {
3
3
  copyFile as fsCopyFile,
4
4
  readFile as fsReadFile,
5
5
  rename as fsRename,
6
+ lstat,
6
7
  mkdir,
7
8
  readdir,
8
9
  rm,
@@ -69,12 +70,22 @@ export interface ContextEntry {
69
70
  path: string;
70
71
  is_directory: boolean;
71
72
  is_textual: boolean;
73
+ /**
74
+ * True when the entry's path under `context/` is a symlink (set from
75
+ * `lstat`). The agent can read and delete the link, but writes that
76
+ * traverse a symlink fail with PathEscapeError so external content is
77
+ * never modified.
78
+ */
79
+ is_symlink: boolean;
72
80
  size: number;
73
81
  mime_type: string;
74
82
  mtime: Date;
75
83
  content_hash: string | null;
76
84
  }
77
85
 
86
+ /** Hard cap on directory recursion across walks; defends against pathological symlink graphs. */
87
+ const MAX_WALK_DEPTH = 32;
88
+
78
89
  const TEXTUAL_EXTENSIONS = new Set([
79
90
  "md",
80
91
  "markdown",
@@ -156,12 +167,18 @@ export function normalizeContextPath(path: string): string {
156
167
 
157
168
  /**
158
169
  * Resolve a context-relative path to an absolute filesystem path under
159
- * `<projectDir>/context/`. Throws PathEscapeError on traversal, symlinks,
160
- * NUL bytes, or attempts to resolve into a protected area.
170
+ * `<projectDir>/context/`. Throws PathEscapeError on traversal, NUL bytes,
171
+ * or attempts to resolve into a protected area.
172
+ *
173
+ * `allowSymlinks` is the opt-in for read-side callers (read, list, tree,
174
+ * info, reindex, delete). Mutating callers (write, edit, mv, cp, mkdir)
175
+ * leave it `false` so user-placed symlinks under `context/` cannot be
176
+ * traversed to modify external content.
161
177
  */
162
178
  async function resolveContext(
163
179
  projectDir: string,
164
180
  path: string,
181
+ opts: { allowSymlinks?: boolean } = {},
165
182
  ): Promise<string> {
166
183
  const normalized = normalizeContextPath(path);
167
184
  if (PROTECTED_AREAS.has(normalized)) {
@@ -172,6 +189,7 @@ async function resolveContext(
172
189
  }
173
190
  return resolveInRoot(projectDir, fromPosix(normalized), {
174
191
  area: CONTEXT_DIR,
192
+ allowSymlinks: opts.allowSymlinks,
175
193
  });
176
194
  }
177
195
 
@@ -195,7 +213,7 @@ export async function fileExists(
195
213
  projectDir: string,
196
214
  path: string,
197
215
  ): Promise<boolean> {
198
- const abs = await resolveContext(projectDir, path);
216
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
199
217
  try {
200
218
  await stat(abs);
201
219
  return true;
@@ -209,20 +227,45 @@ export async function getInfo(
209
227
  projectDir: string,
210
228
  path: string,
211
229
  ): Promise<ContextEntry | null> {
212
- const abs = await resolveContext(projectDir, path);
213
- let st: Awaited<ReturnType<typeof stat>>;
230
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
231
+ let lst: Awaited<ReturnType<typeof lstat>>;
214
232
  try {
215
- st = await stat(abs);
233
+ lst = await lstat(abs);
216
234
  } catch (err) {
217
235
  if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
218
236
  throw err;
219
237
  }
238
+ const isSymlink = lst.isSymbolicLink();
239
+ let st: Awaited<ReturnType<typeof stat>>;
240
+ if (isSymlink) {
241
+ try {
242
+ st = await stat(abs);
243
+ } catch (err) {
244
+ // Broken symlink — surface as a zero-byte symlink entry.
245
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
246
+ return {
247
+ path: normalizeContextPath(path),
248
+ is_directory: false,
249
+ is_textual: false,
250
+ is_symlink: true,
251
+ size: 0,
252
+ mime_type: "application/octet-stream",
253
+ mtime: lst.mtime,
254
+ content_hash: null,
255
+ };
256
+ }
257
+ throw err;
258
+ }
259
+ } else {
260
+ st = lst;
261
+ }
220
262
  const normalized = normalizeContextPath(path);
221
263
  if (st.isDirectory()) {
222
264
  return {
223
265
  path: normalized,
224
266
  is_directory: true,
225
267
  is_textual: false,
268
+ is_symlink: isSymlink,
226
269
  size: 0,
227
270
  mime_type: "inode/directory",
228
271
  mtime: st.mtime,
@@ -234,6 +277,7 @@ export async function getInfo(
234
277
  path: normalized,
235
278
  is_directory: false,
236
279
  is_textual: textual,
280
+ is_symlink: isSymlink,
237
281
  size: st.size,
238
282
  mime_type: mime,
239
283
  mtime: st.mtime,
@@ -245,7 +289,7 @@ export async function readContextFile(
245
289
  projectDir: string,
246
290
  path: string,
247
291
  ): Promise<string> {
248
- const abs = await resolveContext(projectDir, path);
292
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
249
293
  let st: Awaited<ReturnType<typeof stat>>;
250
294
  try {
251
295
  st = await stat(abs);
@@ -298,31 +342,42 @@ export async function deleteContextPath(
298
342
  projectDir: string,
299
343
  path: string,
300
344
  opts: { recursive?: boolean } = {},
301
- ): Promise<{ removed: number; was_directory: boolean }> {
302
- const abs = await resolveContext(projectDir, path);
345
+ ): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
346
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
303
347
  const normalized = normalizeContextPath(path);
304
348
  if (normalized === "") {
305
349
  throw new PathEscapeError("refusing to delete the context root", path);
306
350
  }
307
- let st: Awaited<ReturnType<typeof stat>>;
351
+ let lst: Awaited<ReturnType<typeof lstat>>;
308
352
  try {
309
- st = await stat(abs);
353
+ lst = await lstat(abs);
310
354
  } catch (err) {
311
355
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
312
356
  throw new NotFoundError(normalized);
313
357
  }
314
358
  throw err;
315
359
  }
316
- if (st.isDirectory()) {
360
+ // A symlink (to a file or a directory, broken or not) is removed with a
361
+ // plain unlink — never follow into the target. This is what enforces
362
+ // "the symlink can be deleted, but not the original content".
363
+ if (lst.isSymbolicLink()) {
364
+ await unlink(abs);
365
+ return { removed: 1, was_directory: false, was_symlink: true };
366
+ }
367
+ if (lst.isDirectory()) {
317
368
  if (!opts.recursive) {
318
369
  throw new IsDirectoryError(normalized);
319
370
  }
320
371
  const removedPaths = await collectFiles(abs);
321
372
  await rm(abs, { recursive: true, force: false });
322
- return { removed: removedPaths.length, was_directory: true };
373
+ return {
374
+ removed: removedPaths.length,
375
+ was_directory: true,
376
+ was_symlink: false,
377
+ };
323
378
  }
324
379
  await unlink(abs);
325
- return { removed: 1, was_directory: false };
380
+ return { removed: 1, was_directory: false, was_symlink: false };
326
381
  }
327
382
 
328
383
  export async function moveContextPath(
@@ -392,7 +447,7 @@ export async function listContextDir(
392
447
  path: string,
393
448
  opts: { recursive?: boolean } = {},
394
449
  ): Promise<ContextEntry[]> {
395
- const abs = await resolveContext(projectDir, path);
450
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
396
451
  let st: Awaited<ReturnType<typeof stat>>;
397
452
  try {
398
453
  st = await stat(abs);
@@ -406,11 +461,15 @@ export async function listContextDir(
406
461
  throw new NotDirectoryError(normalizeContextPath(path));
407
462
  }
408
463
  const out: ContextEntry[] = [];
464
+ const visited = new Set<string>();
465
+ visited.add(`${st.dev}:${st.ino}`);
409
466
  await walk(
410
467
  abs,
411
468
  canonicalContextRoot(projectDir),
412
469
  opts.recursive ?? false,
413
470
  out,
471
+ visited,
472
+ 0,
414
473
  );
415
474
  out.sort((a, b) => a.path.localeCompare(b.path));
416
475
  return out;
@@ -421,27 +480,58 @@ async function walk(
421
480
  contextRoot: string,
422
481
  recursive: boolean,
423
482
  acc: ContextEntry[],
483
+ visited: Set<string>,
484
+ depth: number,
424
485
  ): Promise<void> {
486
+ if (depth >= MAX_WALK_DEPTH) return;
425
487
  const names = await readdir(dir);
426
488
  for (const name of names) {
427
489
  if (name.startsWith(".")) continue;
428
490
  const abs = join(dir, name);
429
491
  const rel = toPosix(relative(contextRoot, abs));
430
- const { lstat } = await import("node:fs/promises");
431
- const st = await lstat(abs);
432
- if (st.isSymbolicLink()) continue;
492
+ const lst = await lstat(abs);
493
+ const isSymlink = lst.isSymbolicLink();
494
+ let st: Awaited<ReturnType<typeof stat>>;
495
+ if (isSymlink) {
496
+ try {
497
+ st = await stat(abs);
498
+ } catch (err) {
499
+ // Broken symlink — surface as a zero-byte symlink leaf so the agent
500
+ // can see and remove it, but don't try to recurse into it.
501
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
502
+ acc.push({
503
+ path: rel,
504
+ is_directory: false,
505
+ is_textual: false,
506
+ is_symlink: true,
507
+ size: 0,
508
+ mime_type: "application/octet-stream",
509
+ mtime: lst.mtime,
510
+ content_hash: null,
511
+ });
512
+ continue;
513
+ }
514
+ throw err;
515
+ }
516
+ } else {
517
+ st = lst;
518
+ }
433
519
  if (st.isDirectory()) {
434
520
  acc.push({
435
521
  path: rel,
436
522
  is_directory: true,
437
523
  is_textual: false,
524
+ is_symlink: isSymlink,
438
525
  size: 0,
439
526
  mime_type: "inode/directory",
440
527
  mtime: st.mtime,
441
528
  content_hash: null,
442
529
  });
443
530
  if (recursive) {
444
- await walk(abs, contextRoot, recursive, acc);
531
+ const key = `${st.dev}:${st.ino}`;
532
+ if (visited.has(key)) continue;
533
+ visited.add(key);
534
+ await walk(abs, contextRoot, recursive, acc, visited, depth + 1);
445
535
  }
446
536
  } else if (st.isFile()) {
447
537
  const { mime, textual } = inferMimeType(rel);
@@ -449,6 +539,7 @@ async function walk(
449
539
  path: rel,
450
540
  is_directory: false,
451
541
  is_textual: textual,
542
+ is_symlink: isSymlink,
452
543
  size: st.size,
453
544
  mime_type: mime,
454
545
  mtime: st.mtime,
@@ -458,10 +549,25 @@ async function walk(
458
549
  }
459
550
  }
460
551
 
552
+ /**
553
+ * Collect all real file paths under `absDir`, following symlinks (including
554
+ * symlinked directories) once each. Used for delete-count reporting and
555
+ * `dirSizeBytes`. Symlinked entries are returned as the *symlink path*
556
+ * relative to the walk root, not the resolved target — callers like the
557
+ * delete reporter want the agent-visible path. Cycles are prevented via a
558
+ * `dev:ino` visited set seeded with `absDir` itself.
559
+ */
461
560
  async function collectFiles(absDir: string): Promise<string[]> {
462
561
  const out: string[] = [];
463
- const { lstat } = await import("node:fs/promises");
464
- async function recurse(d: string): Promise<void> {
562
+ const visited = new Set<string>();
563
+ try {
564
+ const rootSt = await stat(absDir);
565
+ visited.add(`${rootSt.dev}:${rootSt.ino}`);
566
+ } catch {
567
+ return out;
568
+ }
569
+ async function recurse(d: string, depth: number): Promise<void> {
570
+ if (depth >= MAX_WALK_DEPTH) return;
465
571
  let names: string[];
466
572
  try {
467
573
  names = await readdir(d);
@@ -470,13 +576,24 @@ async function collectFiles(absDir: string): Promise<string[]> {
470
576
  }
471
577
  for (const name of names) {
472
578
  const abs = join(d, name);
473
- const st = await lstat(abs);
474
- if (st.isSymbolicLink()) continue;
475
- if (st.isDirectory()) await recurse(abs);
476
- else if (st.isFile()) out.push(abs);
579
+ let st: Awaited<ReturnType<typeof stat>>;
580
+ try {
581
+ st = await stat(abs);
582
+ } catch {
583
+ // Broken symlink or permission issue — skip silently.
584
+ continue;
585
+ }
586
+ if (st.isDirectory()) {
587
+ const key = `${st.dev}:${st.ino}`;
588
+ if (visited.has(key)) continue;
589
+ visited.add(key);
590
+ await recurse(abs, depth + 1);
591
+ } else if (st.isFile()) {
592
+ out.push(abs);
593
+ }
477
594
  }
478
595
  }
479
- await recurse(absDir);
596
+ await recurse(absDir, 0);
480
597
  return out;
481
598
  }
482
599
 
@@ -484,6 +601,7 @@ export interface TreeNode {
484
601
  name: string;
485
602
  path: string;
486
603
  is_directory: boolean;
604
+ is_symlink?: boolean;
487
605
  size?: number;
488
606
  children?: TreeNode[];
489
607
  }
@@ -493,7 +611,14 @@ export async function buildTree(
493
611
  path: string,
494
612
  maxDepth = 16,
495
613
  ): Promise<TreeNode> {
496
- const abs = await resolveContext(projectDir, path);
614
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
615
+ const lst = await lstat(abs).catch((err) => {
616
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
617
+ throw new NotFoundError(normalizeContextPath(path));
618
+ }
619
+ throw err;
620
+ });
621
+ const isSymlink = lst.isSymbolicLink();
497
622
  let st: Awaited<ReturnType<typeof stat>>;
498
623
  try {
499
624
  st = await stat(abs);
@@ -511,10 +636,13 @@ export async function buildTree(
511
636
  name,
512
637
  path: rel,
513
638
  is_directory: false,
639
+ ...(isSymlink ? { is_symlink: true } : {}),
514
640
  size: st.size,
515
641
  };
516
642
  }
517
- return treeRecurse(abs, rel, name, root, maxDepth);
643
+ const visited = new Set<string>();
644
+ visited.add(`${st.dev}:${st.ino}`);
645
+ return treeRecurse(abs, rel, name, root, maxDepth, visited, isSymlink);
518
646
  }
519
647
 
520
648
  async function treeRecurse(
@@ -523,11 +651,14 @@ async function treeRecurse(
523
651
  name: string,
524
652
  contextRoot: string,
525
653
  depthLeft: number,
654
+ visited: Set<string>,
655
+ isSymlink: boolean,
526
656
  ): Promise<TreeNode> {
527
657
  const node: TreeNode = {
528
658
  name,
529
659
  path: rel,
530
660
  is_directory: true,
661
+ ...(isSymlink ? { is_symlink: true } : {}),
531
662
  children: [],
532
663
  };
533
664
  if (depthLeft <= 0) return node;
@@ -538,24 +669,66 @@ async function treeRecurse(
538
669
  return node;
539
670
  }
540
671
  names.sort((a, b) => a.localeCompare(b));
541
- const { lstat } = await import("node:fs/promises");
542
672
  const children = node.children ?? [];
543
673
  for (const name of names) {
544
674
  if (name.startsWith(".")) continue;
545
675
  const childAbs = join(abs, name);
546
- const st = await lstat(childAbs);
547
- if (st.isSymbolicLink()) continue;
676
+ const lst = await lstat(childAbs);
677
+ const childIsSymlink = lst.isSymbolicLink();
678
+ let childSt: Awaited<ReturnType<typeof stat>>;
679
+ if (childIsSymlink) {
680
+ try {
681
+ childSt = await stat(childAbs);
682
+ } catch (err) {
683
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
684
+ // Broken symlink — render as zero-byte leaf so it shows in the tree.
685
+ children.push({
686
+ name,
687
+ path: toPosix(relative(contextRoot, childAbs)),
688
+ is_directory: false,
689
+ is_symlink: true,
690
+ size: 0,
691
+ });
692
+ continue;
693
+ }
694
+ throw err;
695
+ }
696
+ } else {
697
+ childSt = lst;
698
+ }
548
699
  const childRel = toPosix(relative(contextRoot, childAbs));
549
- if (st.isDirectory()) {
700
+ if (childSt.isDirectory()) {
701
+ const key = `${childSt.dev}:${childSt.ino}`;
702
+ if (visited.has(key)) {
703
+ // Cycle — render as a stub directory with no children.
704
+ children.push({
705
+ name,
706
+ path: childRel,
707
+ is_directory: true,
708
+ ...(childIsSymlink ? { is_symlink: true } : {}),
709
+ children: [],
710
+ });
711
+ continue;
712
+ }
713
+ visited.add(key);
550
714
  children.push(
551
- await treeRecurse(childAbs, childRel, name, contextRoot, depthLeft - 1),
715
+ await treeRecurse(
716
+ childAbs,
717
+ childRel,
718
+ name,
719
+ contextRoot,
720
+ depthLeft - 1,
721
+ visited,
722
+ childIsSymlink,
723
+ ),
552
724
  );
553
- } else if (st.isFile()) {
725
+ } else if (childSt.isFile()) {
554
726
  children.push({
555
727
  name,
556
728
  path: childRel,
557
729
  is_directory: false,
558
- size: st.size,
730
+ ...(childIsSymlink ? { is_symlink: true } : {}),
731
+ size: childSt.size,
559
732
  });
560
733
  }
561
734
  }
@@ -567,7 +740,7 @@ export async function dirSizeBytes(
567
740
  projectDir: string,
568
741
  path: string,
569
742
  ): Promise<{ files: number; bytes: number }> {
570
- const abs = await resolveContext(projectDir, path);
743
+ const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
571
744
  let st: Awaited<ReturnType<typeof stat>>;
572
745
  try {
573
746
  st = await stat(abs);
package/src/fs/sandbox.ts CHANGED
@@ -27,6 +27,15 @@ export interface SandboxOptions {
27
27
  * operations like list/tree). Default true.
28
28
  */
29
29
  allowRoot?: boolean;
30
+ /**
31
+ * Permit user-placed symlinks anywhere along the resolved path. The
32
+ * containment check on the user-supplied path is unchanged — only the
33
+ * lstat-walk that rejects symlink components is skipped. Read-side
34
+ * callers (read, list, tree, reindex) opt in; mutating callers do not,
35
+ * so the agent can never write through a user symlink to external
36
+ * content.
37
+ */
38
+ allowSymlinks?: boolean;
30
39
  }
31
40
 
32
41
  let cachedCanonicalRoot: string | null = null;
@@ -65,8 +74,9 @@ export function getCanonicalRoot(rawRoot: string): string {
65
74
  * 3. After path.resolve, the result must be inside the (canonical) root or
66
75
  * `<root>/<area>`.
67
76
  * 4. `..` components after normalization are rejected as defense in depth.
68
- * 5. Every existing path component is `lstat`'d; any symlink is rejected.
69
- * Hardlinks are out of scope by design.
77
+ * 5. Every existing path component is `lstat`'d; any symlink is rejected
78
+ * unless `allowSymlinks` is set (read-only callers opt in so users can
79
+ * symlink content into `<root>/context/`). Hardlinks are out of scope.
70
80
  *
71
81
  * Returns the absolute, canonical path safe to pass to fs APIs.
72
82
  */
@@ -86,7 +96,9 @@ export async function resolveInRoot(
86
96
  const resolved = resolve(boundary, normalized);
87
97
  ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
88
98
 
89
- await assertNoSymlinkComponents(resolved, canonicalRoot);
99
+ if (!opts.allowSymlinks) {
100
+ await assertNoSymlinkComponents(resolved, canonicalRoot);
101
+ }
90
102
  return resolved;
91
103
  }
92
104
 
@@ -109,7 +121,9 @@ export function resolveInRootSync(
109
121
  const resolved = resolve(boundary, normalized);
110
122
  ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
111
123
 
112
- assertNoSymlinkComponentsSync(resolved, canonicalRoot);
124
+ if (!opts.allowSymlinks) {
125
+ assertNoSymlinkComponentsSync(resolved, canonicalRoot);
126
+ }
113
127
  return resolved;
114
128
  }
115
129
 
@@ -18,7 +18,7 @@ const outputSchema = z.object({
18
18
  export const contextCreateDirTool = {
19
19
  name: "context_create_dir",
20
20
  description:
21
- "[[ bash equivalent command: mkdir -p ]] Create a directory (recursively) under context/.",
21
+ "[[ bash equivalent command: mkdir -p ]] Create a directory (recursively) under context/. Paths that traverse a user symlink fail with PathEscapeError.",
22
22
  group: "context",
23
23
  inputSchema,
24
24
  outputSchema,
@@ -21,7 +21,8 @@ export interface BuildContextTreeResult {
21
21
  function renderTree(node: TreeNode, prefix = "", isLast = true): string[] {
22
22
  const lines: string[] = [];
23
23
  const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
24
- const label = node.is_directory ? `${node.name}/` : node.name;
24
+ const base = node.is_directory ? `${node.name}/` : node.name;
25
+ const label = node.is_symlink ? `${base} -> (symlink)` : base;
25
26
  lines.push(`${prefix}${connector}${label}`);
26
27
  if (node.is_directory && node.children) {
27
28
  const childPrefix =
@@ -72,7 +73,7 @@ const outputSchema = z.object({
72
73
  export const contextTreeTool = {
73
74
  name: "context_tree",
74
75
  description:
75
- "[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory).",
76
+ "[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory). Symlinks are followed for listing and indexing; entries that are symlinks are tagged ' -> (symlink)' in the output.",
76
77
  group: "context",
77
78
  inputSchema,
78
79
  outputSchema,
@@ -26,7 +26,7 @@ const outputSchema = z.object({
26
26
  export const contextCopyTool = {
27
27
  name: "context_copy",
28
28
  description:
29
- "[[ bash equivalent command: cp ]] Copy a file under context/ to a new path.",
29
+ "[[ bash equivalent command: cp ]] Copy a file under context/ to a new path. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
30
30
  group: "context",
31
31
  inputSchema,
32
32
  outputSchema,
@@ -21,6 +21,7 @@ const inputSchema = z.object({
21
21
  const outputSchema = z.object({
22
22
  deleted: z.number(),
23
23
  was_directory: z.boolean(),
24
+ was_symlink: z.boolean(),
24
25
  is_error: z.boolean(),
25
26
  error_type: z.string().optional(),
26
27
  message: z.string().optional(),
@@ -30,7 +31,7 @@ const outputSchema = z.object({
30
31
  export const contextDeleteTool = {
31
32
  name: "context_delete",
32
33
  description:
33
- "[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/.",
34
+ "[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/. Symlinks are unlinked without touching their target — `recursive` is not required for a symlinked directory.",
34
35
  group: "context",
35
36
  inputSchema,
36
37
  outputSchema,
@@ -42,16 +43,23 @@ export const contextDeleteTool = {
42
43
  return {
43
44
  deleted: result.removed,
44
45
  was_directory: result.was_directory,
46
+ was_symlink: result.was_symlink,
45
47
  is_error: false,
46
48
  };
47
49
  } catch (err) {
48
50
  if (err instanceof NotFoundError) {
49
51
  if (input.force) {
50
- return { deleted: 0, was_directory: false, is_error: false };
52
+ return {
53
+ deleted: 0,
54
+ was_directory: false,
55
+ was_symlink: false,
56
+ is_error: false,
57
+ };
51
58
  }
52
59
  return {
53
60
  deleted: 0,
54
61
  was_directory: false,
62
+ was_symlink: false,
55
63
  is_error: true,
56
64
  error_type: "not_found",
57
65
  message: `No file at context/${err.path}`,
@@ -61,6 +69,7 @@ export const contextDeleteTool = {
61
69
  return {
62
70
  deleted: 0,
63
71
  was_directory: true,
72
+ was_symlink: false,
64
73
  is_error: true,
65
74
  error_type: "is_directory",
66
75
  message: `context/${err.path} is a directory`,
@@ -33,7 +33,7 @@ const outputSchema = z.object({
33
33
  export const contextEditTool = {
34
34
  name: "context_edit",
35
35
  description:
36
- "[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content.",
36
+ "[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content. Edits that traverse a user symlink fail with PathEscapeError — delete the symlink first or copy the content to a real path.",
37
37
  group: "context",
38
38
  inputSchema,
39
39
  outputSchema,
@@ -10,6 +10,7 @@ const fileSchema = z.object({
10
10
  path: z.string(),
11
11
  is_directory: z.boolean(),
12
12
  is_textual: z.boolean(),
13
+ is_symlink: z.boolean(),
13
14
  mime_type: z.string(),
14
15
  size: z.number(),
15
16
  lines: z.number(),
@@ -28,7 +29,7 @@ const outputSchema = z.object({
28
29
  export const contextInfoTool = {
29
30
  name: "context_info",
30
31
  description:
31
- "[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash.",
32
+ "[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash. `is_symlink` is true when the path is a user-placed symlink.",
32
33
  group: "context",
33
34
  inputSchema,
34
35
  outputSchema,
@@ -52,6 +53,7 @@ export const contextInfoTool = {
52
53
  path: info.path,
53
54
  is_directory: info.is_directory,
54
55
  is_textual: info.is_textual,
56
+ is_symlink: info.is_symlink,
55
57
  mime_type: info.mime_type,
56
58
  size: info.size,
57
59
  lines,
@@ -25,7 +25,7 @@ const outputSchema = z.object({
25
25
  export const contextMoveTool = {
26
26
  name: "context_move",
27
27
  description:
28
- "[[ bash equivalent command: mv ]] Move or rename a file/directory under context/.",
28
+ "[[ bash equivalent command: mv ]] Move or rename a file/directory under context/. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
29
29
  group: "context",
30
30
  inputSchema,
31
31
  outputSchema,
@@ -28,7 +28,7 @@ const outputSchema = z.object({
28
28
  export const contextWriteTool = {
29
29
  name: "context_write",
30
30
  description:
31
- "[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'.",
31
+ "[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'. Writes that traverse a user symlink fail with PathEscapeError — delete the symlink first or write to a real path.",
32
32
  group: "context",
33
33
  inputSchema,
34
34
  outputSchema,
@@ -50,6 +50,8 @@ import { listThreadsTool } from "./thread/list.ts";
50
50
  import { searchThreadsTool } from "./thread/search.ts";
51
51
  import { viewThreadTool } from "./thread/view.ts";
52
52
  import { registerTool } from "./tool.ts";
53
+ // Util tools
54
+ import { sleepTool } from "./util/sleep.ts";
53
55
  // Worker tools
54
56
  import { spawnWorkerTool } from "./worker/spawn.ts";
55
57
 
@@ -111,6 +113,9 @@ export function registerAllTools(): void {
111
113
  registerTool(mcpInfoTool);
112
114
  registerTool(mcpExecTool);
113
115
 
116
+ // Util
117
+ registerTool(sleepTool);
118
+
114
119
  // Worker
115
120
  registerTool(spawnWorkerTool);
116
121
  }
package/src/tools/tool.ts CHANGED
@@ -17,6 +17,11 @@ export interface ToolContext {
17
17
  projectDir: string;
18
18
  config: Required<BotholomewConfig>;
19
19
  mcpxClient: McpxClient | null;
20
+ /**
21
+ * Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
22
+ * Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
23
+ */
24
+ shouldAbort?: () => boolean;
20
25
  }
21
26
 
22
27
  type ToolOutputBase = { is_error: z.ZodBoolean };
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "../tool.ts";
3
+
4
+ const MIN_SECONDS = 1;
5
+ const MAX_SECONDS = 3600;
6
+ const POLL_INTERVAL_MS = 250;
7
+
8
+ const inputSchema = z.object({
9
+ seconds: z
10
+ .number()
11
+ .int()
12
+ .min(MIN_SECONDS)
13
+ .max(MAX_SECONDS)
14
+ .describe(
15
+ `How long to sleep, in seconds (${MIN_SECONDS}–${MAX_SECONDS}). For longer pauses, create a schedule instead.`,
16
+ ),
17
+ reason: z
18
+ .string()
19
+ .min(1)
20
+ .describe(
21
+ "Why you're sleeping — shown to the user under the progress bar. Be specific (e.g. 'waiting for worker to finish task abc').",
22
+ ),
23
+ });
24
+
25
+ const outputSchema = z.object({
26
+ message: z.string(),
27
+ slept_seconds: z.number(),
28
+ aborted: z.boolean(),
29
+ is_error: z.boolean(),
30
+ });
31
+
32
+ export const sleepTool = {
33
+ name: "sleep",
34
+ description:
35
+ "[[ bash equivalent command: sleep ]] Pause the chat agent for a fixed number of seconds. Useful after enqueuing tasks for workers, before checking results. The user sees a progress bar while you wait; pressing Esc cancels the wait. Returns when the time elapses or the user steers.",
36
+ group: "util",
37
+ inputSchema,
38
+ outputSchema,
39
+ execute: async (input, ctx): Promise<z.infer<typeof outputSchema>> => {
40
+ const startedAt = Date.now();
41
+ const totalMs = input.seconds * 1000;
42
+ const shouldAbort = ctx.shouldAbort;
43
+
44
+ let aborted: boolean = false;
45
+ await new Promise<void>((resolve) => {
46
+ let timeout: ReturnType<typeof setTimeout> | null = null;
47
+ let interval: ReturnType<typeof setInterval> | null = null;
48
+
49
+ const finish = () => {
50
+ if (timeout) clearTimeout(timeout);
51
+ if (interval) clearInterval(interval);
52
+ resolve();
53
+ };
54
+
55
+ timeout = setTimeout(finish, totalMs);
56
+
57
+ if (shouldAbort) {
58
+ interval = setInterval(() => {
59
+ if (shouldAbort()) {
60
+ aborted = true;
61
+ finish();
62
+ }
63
+ }, POLL_INTERVAL_MS);
64
+ }
65
+ });
66
+
67
+ const sleptSeconds = (Date.now() - startedAt) / 1000;
68
+ return {
69
+ message: aborted
70
+ ? `Sleep interrupted after ${sleptSeconds.toFixed(1)}s of ${input.seconds}s — user steered.`
71
+ : `Slept ${sleptSeconds.toFixed(1)}s. ${input.reason}`,
72
+ slept_seconds: sleptSeconds,
73
+ aborted,
74
+ is_error: false,
75
+ };
76
+ },
77
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,70 @@
1
+ import { Box, Text } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import { theme } from "../theme.ts";
4
+
5
+ interface SleepProgressProps {
6
+ startedAt: Date;
7
+ totalSeconds: number;
8
+ reason?: string;
9
+ }
10
+
11
+ const BAR_WIDTH = 24;
12
+ const TICK_MS = 200;
13
+
14
+ export function SleepProgress({
15
+ startedAt,
16
+ totalSeconds,
17
+ reason,
18
+ }: SleepProgressProps) {
19
+ const [now, setNow] = useState(() => Date.now());
20
+
21
+ useEffect(() => {
22
+ const id = setInterval(() => setNow(Date.now()), TICK_MS);
23
+ return () => clearInterval(id);
24
+ }, []);
25
+
26
+ const totalMs = totalSeconds * 1000;
27
+ const elapsedMs = Math.min(totalMs, Math.max(0, now - startedAt.getTime()));
28
+ const ratio = totalMs > 0 ? elapsedMs / totalMs : 1;
29
+ const filled = Math.round(ratio * BAR_WIDTH);
30
+ const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
31
+ const elapsedSec = (elapsedMs / 1000).toFixed(1);
32
+
33
+ return (
34
+ <Box flexDirection="column">
35
+ <Box>
36
+ <Text dimColor>{" "}</Text>
37
+ <Text color={theme.accent}>{bar}</Text>
38
+ <Text dimColor>
39
+ {" "}
40
+ {elapsedSec}s / {totalSeconds}s
41
+ </Text>
42
+ </Box>
43
+ {reason && (
44
+ <Text dimColor wrap="truncate-end">
45
+ {" "}
46
+ {reason}
47
+ </Text>
48
+ )}
49
+ </Box>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Pull `seconds` and `reason` out of a sleep tool's stringified JSON input.
55
+ * Returns `null` if the input can't be parsed or doesn't have a numeric duration.
56
+ */
57
+ export function parseSleepInput(
58
+ raw: string,
59
+ ): { seconds: number; reason?: string } | null {
60
+ try {
61
+ const parsed = JSON.parse(raw);
62
+ if (typeof parsed?.seconds !== "number") return null;
63
+ return {
64
+ seconds: parsed.seconds,
65
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
66
+ };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { theme } from "../theme.ts";
3
+ import { parseSleepInput, SleepProgress } from "./SleepProgress.tsx";
3
4
 
4
5
  /**
5
6
  * For mcp_exec calls, extract server/tool into a top-level display name
@@ -53,6 +54,8 @@ export function ToolCall({ tool }: ToolCallProps) {
53
54
  const truncatedInput =
54
55
  displayInput.length > 60 ? `${displayInput.slice(0, 60)}…` : displayInput;
55
56
  const truncatedOutput = tool.output ? tool.output.slice(0, 120) : "";
57
+ const sleepArgs =
58
+ tool.name === "sleep" && tool.running ? parseSleepInput(tool.input) : null;
56
59
 
57
60
  return (
58
61
  <Box flexDirection="column">
@@ -83,6 +86,13 @@ export function ToolCall({ tool }: ToolCallProps) {
83
86
  {tool.name === "mcp_exec" && <Text dimColor> (exec)</Text>}
84
87
  <Text dimColor> ({truncatedInput})</Text>
85
88
  </Box>
89
+ {sleepArgs && (
90
+ <SleepProgress
91
+ startedAt={tool.timestamp}
92
+ totalSeconds={sleepArgs.seconds}
93
+ reason={sleepArgs.reason}
94
+ />
95
+ )}
86
96
  {truncatedOutput && !tool.running && (
87
97
  <Text dimColor wrap="truncate-end">
88
98
  {" → "}