@oh-my-pi/pi-coding-agent 6.7.67 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +6 -7
  3. package/src/cli/session-picker.ts +27 -28
  4. package/src/cli/setup-cli.ts +7 -16
  5. package/src/cli/update-cli.ts +1 -1
  6. package/src/config.ts +1 -1
  7. package/src/core/agent-session.ts +202 -37
  8. package/src/core/agent-storage.ts +1 -1
  9. package/src/core/auth-storage.ts +15 -25
  10. package/src/core/bash-executor.ts +63 -105
  11. package/src/core/custom-commands/loader.ts +1 -1
  12. package/src/core/custom-tools/loader.ts +1 -1
  13. package/src/core/custom-tools/types.ts +1 -2
  14. package/src/core/exec.ts +16 -100
  15. package/src/core/extensions/index.ts +1 -7
  16. package/src/core/extensions/loader.ts +1 -1
  17. package/src/core/extensions/runner.ts +1 -1
  18. package/src/core/extensions/types.ts +2 -2
  19. package/src/core/extensions/wrapper.ts +15 -20
  20. package/src/core/frontmatter.ts +1 -1
  21. package/src/core/history-storage.ts +3 -6
  22. package/src/core/hooks/index.ts +2 -2
  23. package/src/core/hooks/loader.ts +1 -1
  24. package/src/core/hooks/tool-wrapper.ts +14 -26
  25. package/src/core/hooks/types.ts +1 -2
  26. package/src/core/keybindings.ts +1 -1
  27. package/src/core/mcp/client.ts +13 -13
  28. package/src/core/mcp/json-rpc.ts +1 -1
  29. package/src/core/mcp/loader.ts +1 -1
  30. package/src/core/mcp/manager.ts +2 -2
  31. package/src/core/mcp/tool-cache.ts +1 -1
  32. package/src/core/mcp/transports/http.ts +32 -70
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/plugins/installer.ts +13 -11
  35. package/src/core/prompt-templates.ts +4 -9
  36. package/src/core/python-executor.ts +23 -18
  37. package/src/core/python-gateway-coordinator.ts +29 -28
  38. package/src/core/python-kernel.ts +230 -211
  39. package/src/core/sdk.ts +10 -13
  40. package/src/core/session-manager.ts +1 -1
  41. package/src/core/settings-manager.ts +22 -9
  42. package/src/core/skills.ts +1 -1
  43. package/src/core/ssh/connection-manager.ts +19 -33
  44. package/src/core/ssh/ssh-executor.ts +39 -35
  45. package/src/core/ssh/sshfs-mount.ts +14 -33
  46. package/src/core/storage-migration.ts +1 -1
  47. package/src/core/streaming-output.ts +183 -127
  48. package/src/core/system-prompt.ts +119 -79
  49. package/src/core/title-generator.ts +1 -1
  50. package/src/core/tools/ask.ts +2 -2
  51. package/src/core/tools/bash.ts +3 -3
  52. package/src/core/tools/calculator.ts +1 -1
  53. package/src/core/tools/exa/mcp-client.ts +1 -1
  54. package/src/core/tools/exa/render.ts +1 -1
  55. package/src/core/tools/find.ts +39 -71
  56. package/src/core/tools/gemini-image.ts +1 -1
  57. package/src/core/tools/grep.ts +88 -100
  58. package/src/core/tools/index.ts +1 -1
  59. package/src/core/tools/ls.ts +1 -1
  60. package/src/core/tools/lsp/client.ts +50 -50
  61. package/src/core/tools/lsp/clients/lsp-linter-client.ts +1 -1
  62. package/src/core/tools/lsp/config.ts +1 -1
  63. package/src/core/tools/lsp/index.ts +2 -4
  64. package/src/core/tools/lsp/lspmux.ts +1 -1
  65. package/src/core/tools/lsp/rust-analyzer.ts +2 -2
  66. package/src/core/tools/lsp/utils.ts +0 -14
  67. package/src/core/tools/notebook.ts +1 -1
  68. package/src/core/tools/patch/shared.ts +3 -4
  69. package/src/core/tools/python.ts +3 -3
  70. package/src/core/tools/read.ts +29 -68
  71. package/src/core/tools/render-utils.ts +0 -5
  72. package/src/core/tools/ssh.ts +3 -3
  73. package/src/core/tools/task/model-resolver.ts +7 -9
  74. package/src/core/tools/task/worker.ts +144 -139
  75. package/src/core/tools/todo-write.ts +1 -1
  76. package/src/core/tools/truncate.ts +2 -2
  77. package/src/core/tools/web-fetch.ts +13 -15
  78. package/src/core/tools/web-scrapers/types.ts +1 -3
  79. package/src/core/tools/web-scrapers/utils.ts +14 -13
  80. package/src/core/tools/web-scrapers/youtube.ts +39 -12
  81. package/src/core/tools/web-search/auth.ts +9 -45
  82. package/src/core/tools/write.ts +1 -1
  83. package/src/core/ttsr.ts +1 -1
  84. package/src/core/utils.ts +1 -187
  85. package/src/core/voice-controller.ts +1 -1
  86. package/src/core/voice-supervisor.ts +11 -38
  87. package/src/core/voice.ts +1 -8
  88. package/src/discovery/codex.ts +1 -1
  89. package/src/index.ts +4 -4
  90. package/src/main.ts +5 -10
  91. package/src/migrations.ts +1 -1
  92. package/src/modes/index.ts +7 -40
  93. package/src/modes/interactive/components/extensions/state-manager.ts +1 -1
  94. package/src/modes/interactive/components/hook-editor.ts +12 -9
  95. package/src/modes/interactive/components/login-dialog.ts +24 -11
  96. package/src/modes/interactive/components/settings-defs.ts +9 -0
  97. package/src/modes/interactive/components/status-line.ts +36 -35
  98. package/src/modes/interactive/components/todo-display.ts +1 -1
  99. package/src/modes/interactive/components/tool-execution.ts +1 -1
  100. package/src/modes/interactive/controllers/command-controller.ts +50 -84
  101. package/src/modes/interactive/controllers/extension-ui-controller.ts +76 -76
  102. package/src/modes/interactive/controllers/input-controller.ts +12 -11
  103. package/src/modes/interactive/interactive-mode.ts +10 -11
  104. package/src/modes/interactive/theme/theme.ts +1 -1
  105. package/src/modes/interactive/types.ts +1 -1
  106. package/src/modes/rpc/rpc-client.ts +91 -121
  107. package/src/modes/rpc/rpc-mode.ts +71 -79
  108. package/src/prompts/system/ttsr-interrupt.md +7 -0
  109. package/src/utils/clipboard.ts +57 -141
  110. package/src/utils/shell-snapshot.ts +12 -60
  111. package/src/utils/shell.ts +35 -56
  112. package/src/utils/tools-manager.ts +42 -71
  113. package/src/core/logger.ts +0 -111
  114. package/src/modes/cleanup.ts +0 -23
@@ -2,9 +2,10 @@
2
2
  * System prompt construction and project context loading
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { existsSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { $ } from "bun";
8
9
  import chalk from "chalk";
9
10
  import { contextFileCapability } from "../capability/context-file";
10
11
  import { systemPromptCapability } from "../capability/system-prompt";
@@ -16,15 +17,6 @@ import type { SkillsSettings } from "./settings-manager";
16
17
  import { loadSkills, type Skill } from "./skills";
17
18
  import type { ToolName } from "./tools/index";
18
19
 
19
- /**
20
- * Execute a git command synchronously and return stdout or null on failure.
21
- */
22
- function execGit(args: string[], cwd: string): string | null {
23
- const result = Bun.spawnSync(["git", ...args], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
24
- if (result.exitCode !== 0) return null;
25
- return result.stdout.toString().trim() || null;
26
- }
27
-
28
20
  interface GitContext {
29
21
  isRepo: boolean;
30
22
  currentBranch: string;
@@ -37,31 +29,36 @@ interface GitContext {
37
29
  * Load git context for the system prompt.
38
30
  * Returns structured git data or null if not in a git repo.
39
31
  */
40
- export function loadGitContext(cwd: string): GitContext | null {
32
+ export async function loadGitContext(cwd: string): Promise<GitContext | null> {
33
+ const git = (...args: string[]) =>
34
+ $`git ${args}`
35
+ .cwd(cwd)
36
+ .quiet()
37
+ .text()
38
+ .catch(() => null)
39
+ .then((text) => text?.trim() ?? null);
40
+
41
41
  // Check if inside a git repo
42
- const isGitRepo = execGit(["rev-parse", "--is-inside-work-tree"], cwd);
42
+ const isGitRepo = await git("rev-parse", "--is-inside-work-tree");
43
43
  if (isGitRepo !== "true") return null;
44
44
 
45
45
  // Get current branch
46
- const currentBranch = execGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
46
+ const currentBranch = await git("rev-parse", "--abbrev-ref", "HEAD");
47
47
  if (!currentBranch) return null;
48
48
 
49
49
  // Detect main branch (check for 'main' first, then 'master')
50
50
  let mainBranch = "main";
51
- const mainExists = execGit(["rev-parse", "--verify", "main"], cwd);
51
+ const mainExists = await git("rev-parse", "--verify", "main");
52
52
  if (mainExists === null) {
53
- const masterExists = execGit(["rev-parse", "--verify", "master"], cwd);
53
+ const masterExists = await git("rev-parse", "--verify", "master");
54
54
  if (masterExists !== null) mainBranch = "master";
55
55
  }
56
56
 
57
57
  // Get git status (porcelain format for parsing)
58
- const gitStatus = execGit(["status", "--porcelain"], cwd);
59
- const status = gitStatus?.trim() || "(clean)";
58
+ const status = (await git("status", "--porcelain")) || "(clean)";
60
59
 
61
60
  // Get recent commits
62
- const recentCommits = execGit(["log", "--oneline", "-5"], cwd);
63
- const commits = recentCommits?.trim() || "(no commits)";
64
-
61
+ const commits = (await git("log", "--oneline", "-5")) || "(no commits)";
65
62
  return {
66
63
  isRepo: true,
67
64
  currentBranch,
@@ -94,18 +91,6 @@ const toolDescriptions: Record<ToolName, string> = {
94
91
  report_finding: "Report a finding during code review",
95
92
  };
96
93
 
97
- function execCommand(args: string[]): string | null {
98
- const result = Bun.spawnSync(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
99
- if (result.exitCode !== 0) return null;
100
- const output = result.stdout.toString().trim();
101
- return output.length > 0 ? output : null;
102
- }
103
-
104
- function execIfExists(command: string, args: string[]): string | null {
105
- if (!Bun.which(command)) return null;
106
- return execCommand([command, ...args]);
107
- }
108
-
109
94
  function firstNonEmpty(values: Array<string | undefined | null>): string | null {
110
95
  for (const value of values) {
111
96
  const trimmed = value?.trim();
@@ -209,18 +194,27 @@ function getOsName(): string {
209
194
  }
210
195
  }
211
196
 
212
- function getKernelVersion(): string {
197
+ async function getKernelVersion(): Promise<string> {
213
198
  if (process.platform === "win32") {
214
- return execCommand(["cmd", "/c", "ver"]) ?? "unknown";
199
+ return await $`ver`
200
+ .quiet()
201
+ .text()
202
+ .catch(() => "unknown");
203
+ } else {
204
+ return await $`uname -sr`
205
+ .quiet()
206
+ .text()
207
+ .catch(() => "unknown");
215
208
  }
216
-
217
- return execCommand(["uname", "-sr"]) ?? "unknown";
218
209
  }
219
210
 
220
- function getOsDistro(): string | null {
211
+ async function getOsDistro(): Promise<string | null> {
221
212
  switch (process.platform) {
222
213
  case "win32": {
223
- const output = execIfExists("wmic", ["os", "get", "Caption,Version", "/value"]);
214
+ const output = await $`wmic os get Caption,Version /value`
215
+ .quiet()
216
+ .text()
217
+ .catch(() => null);
224
218
  if (!output) return null;
225
219
  const parsed = parseKeyValueOutput(output);
226
220
  const caption = parsed.Caption;
@@ -229,15 +223,32 @@ function getOsDistro(): string | null {
229
223
  return caption ?? version ?? null;
230
224
  }
231
225
  case "darwin": {
232
- const name = firstNonEmptyLine(execIfExists("sw_vers", ["-productName"]));
233
- const version = firstNonEmptyLine(execIfExists("sw_vers", ["-productVersion"]));
226
+ const name = firstNonEmptyLine(
227
+ await $`sw_vers -productName`
228
+ .quiet()
229
+ .text()
230
+ .catch(() => null),
231
+ );
232
+ const version = firstNonEmptyLine(
233
+ await $`sw_vers -productVersion`
234
+ .quiet()
235
+ .text()
236
+ .catch(() => null),
237
+ );
234
238
  if (name && version) return `${name} ${version}`.trim();
235
239
  return name ?? version ?? null;
236
240
  }
237
241
  case "linux": {
238
- const lsb = firstNonEmptyLine(execIfExists("lsb_release", ["-ds"]));
242
+ const lsb = firstNonEmptyLine(
243
+ await $`lsb_release -ds`
244
+ .quiet()
245
+ .text()
246
+ .catch(() => null),
247
+ );
239
248
  if (lsb) return stripQuotes(lsb);
240
- const osRelease = execIfExists("cat", ["/etc/os-release"]);
249
+ const osRelease = await Bun.file("/etc/os-release")
250
+ .text()
251
+ .catch(() => null);
241
252
  if (!osRelease) return null;
242
253
  const parsed = parseKeyValueOutput(osRelease);
243
254
  const pretty = parsed.PRETTY_NAME ?? parsed.NAME;
@@ -255,17 +266,28 @@ function getCpuArch(): string {
255
266
  return process.arch || "unknown";
256
267
  }
257
268
 
258
- function getCpuModel(): string | null {
269
+ async function getCpuModel(): Promise<string | null> {
259
270
  switch (process.platform) {
260
271
  case "win32": {
261
- const output = execIfExists("wmic", ["cpu", "get", "Name"]);
272
+ const output = await $`wmic cpu get Name`
273
+ .quiet()
274
+ .text()
275
+ .catch(() => null);
262
276
  return output ? parseWmicTable(output, "Name") : null;
263
277
  }
264
278
  case "darwin": {
265
- return firstNonEmptyLine(execIfExists("sysctl", ["-n", "machdep.cpu.brand_string"]));
279
+ return firstNonEmptyLine(
280
+ await $`sysctl -n machdep.cpu.brand_string`
281
+ .quiet()
282
+ .text()
283
+ .catch(() => null),
284
+ );
266
285
  }
267
286
  case "linux": {
268
- const lscpu = execIfExists("lscpu", []);
287
+ const lscpu = await $`lscpu`
288
+ .quiet()
289
+ .text()
290
+ .catch(() => null);
269
291
  if (lscpu) {
270
292
  const match = lscpu
271
293
  .split("\n")
@@ -273,7 +295,9 @@ function getCpuModel(): string | null {
273
295
  .find((line) => line.toLowerCase().startsWith("model name:"));
274
296
  if (match) return match.split(":").slice(1).join(":").trim();
275
297
  }
276
- const cpuInfo = execIfExists("cat", ["/proc/cpuinfo"]);
298
+ const cpuInfo = await Bun.file("/proc/cpuinfo")
299
+ .text()
300
+ .catch(() => null);
277
301
  if (!cpuInfo) return null;
278
302
  for (const line of cpuInfo.split("\n")) {
279
303
  const [key, ...rest] = line.split(":");
@@ -290,14 +314,20 @@ function getCpuModel(): string | null {
290
314
  }
291
315
  }
292
316
 
293
- function getGpuModel(): string | null {
317
+ async function getGpuModel(): Promise<string | null> {
294
318
  switch (process.platform) {
295
319
  case "win32": {
296
- const output = execIfExists("wmic", ["path", "win32_VideoController", "get", "name"]);
320
+ const output = await $`wmic path win32_VideoController get name`
321
+ .quiet()
322
+ .text()
323
+ .catch(() => null);
297
324
  return output ? parseWmicTable(output, "Name") : null;
298
325
  }
299
326
  case "linux": {
300
- const output = execIfExists("lspci", []);
327
+ const output = await $`lspci`
328
+ .quiet()
329
+ .text()
330
+ .catch(() => null);
301
331
  if (!output) return null;
302
332
  const gpus: Array<{ name: string; priority: number }> = [];
303
333
  for (const line of output.split("\n")) {
@@ -426,39 +456,42 @@ function getSystemInfoCachePath(): string {
426
456
  return join(homedir(), ".omp", "system_info.json");
427
457
  }
428
458
 
429
- function loadSystemInfoCache(): SystemInfoCache | null {
459
+ async function loadSystemInfoCache(): Promise<SystemInfoCache | null> {
430
460
  try {
431
461
  const cachePath = getSystemInfoCachePath();
432
462
  if (!existsSync(cachePath)) return null;
433
- const content = readFileSync(cachePath, "utf-8");
434
- return JSON.parse(content) as SystemInfoCache;
463
+ const content = await Bun.file(cachePath).json();
464
+ return content as SystemInfoCache;
435
465
  } catch {
436
466
  return null;
437
467
  }
438
468
  }
439
469
 
440
- function saveSystemInfoCache(info: SystemInfoCache): void {
470
+ async function saveSystemInfoCache(info: SystemInfoCache): Promise<void> {
441
471
  try {
442
472
  const cachePath = getSystemInfoCachePath();
443
- const dir = join(homedir(), ".omp");
444
- if (!existsSync(dir)) {
445
- mkdirSync(dir, { recursive: true });
446
- }
447
- writeFileSync(cachePath, JSON.stringify(info, null, "\t"), "utf-8");
473
+ await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
448
474
  } catch {
449
475
  // Silently ignore cache write failures
450
476
  }
451
477
  }
452
478
 
453
- function collectSystemInfo(): SystemInfoCache {
479
+ async function collectSystemInfo(): Promise<SystemInfoCache> {
480
+ const [distro, cpu, gpu, disk, kernel] = await Promise.all([
481
+ getOsDistro(),
482
+ getCpuModel(),
483
+ getGpuModel(),
484
+ getDiskInfo(),
485
+ getKernelVersion(),
486
+ ]);
454
487
  return {
455
488
  os: getOsName(),
456
- distro: getOsDistro() ?? "unknown",
457
- kernel: getKernelVersion(),
489
+ distro: distro ?? "unknown",
490
+ kernel: kernel ?? "unknown",
458
491
  arch: getCpuArch(),
459
- cpu: getCpuModel() ?? "unknown",
460
- gpu: getGpuModel() ?? "unknown",
461
- disk: getDiskInfo() ?? "unknown",
492
+ cpu: cpu ?? "unknown",
493
+ gpu: gpu ?? "unknown",
494
+ disk: disk ?? "unknown",
462
495
  };
463
496
  }
464
497
 
@@ -470,10 +503,13 @@ function formatBytes(bytes: number): string {
470
503
  return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)}TB`;
471
504
  }
472
505
 
473
- function getDiskInfo(): string | null {
506
+ async function getDiskInfo(): Promise<string | null> {
474
507
  switch (process.platform) {
475
508
  case "win32": {
476
- const output = execIfExists("wmic", ["logicaldisk", "get", "Caption,Size,FreeSpace", "/format:csv"]);
509
+ const output = await $`wmic logicaldisk get Caption,Size,FreeSpace /format:csv`
510
+ .quiet()
511
+ .text()
512
+ .catch(() => null);
477
513
  if (!output) return null;
478
514
  const lines = output.split("\n").filter((l) => l.trim() && !l.startsWith("Node"));
479
515
  const disks: string[] = [];
@@ -492,7 +528,10 @@ function getDiskInfo(): string | null {
492
528
  }
493
529
  case "linux":
494
530
  case "darwin": {
495
- const output = execIfExists("df", ["-h", "/"]);
531
+ const output = await $`df -h /`
532
+ .quiet()
533
+ .text()
534
+ .catch(() => null);
496
535
  if (!output) return null;
497
536
  const lines = output.split("\n");
498
537
  if (lines.length < 2) return null;
@@ -508,12 +547,12 @@ function getDiskInfo(): string | null {
508
547
  }
509
548
  }
510
549
 
511
- function getEnvironmentInfo(): Array<{ label: string; value: string }> {
550
+ async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
512
551
  // Load cached system info or collect fresh
513
- let sysInfo = loadSystemInfoCache();
552
+ let sysInfo = await loadSystemInfoCache();
514
553
  if (!sysInfo) {
515
- sysInfo = collectSystemInfo();
516
- saveSystemInfoCache(sysInfo);
554
+ sysInfo = await collectSystemInfo();
555
+ await saveSystemInfoCache(sysInfo);
517
556
  }
518
557
 
519
558
  return [
@@ -532,14 +571,15 @@ function getEnvironmentInfo(): Array<{ label: string; value: string }> {
532
571
  }
533
572
 
534
573
  /** Resolve input as file path or literal string */
535
- export function resolvePromptInput(input: string | undefined, description: string): string | undefined {
574
+ export async function resolvePromptInput(input: string | undefined, description: string): Promise<string | undefined> {
536
575
  if (!input) {
537
576
  return undefined;
538
577
  }
539
578
 
540
- if (existsSync(input)) {
579
+ const file = Bun.file(input);
580
+ if (await file.exists()) {
541
581
  try {
542
- return readFileSync(input, "utf-8");
582
+ return await file.text();
543
583
  } catch (error) {
544
584
  console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
545
585
  return input;
@@ -649,8 +689,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
649
689
  rules,
650
690
  } = options;
651
691
  const resolvedCwd = cwd ?? process.cwd();
652
- const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
653
- const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
692
+ const resolvedCustomPrompt = await resolvePromptInput(customPrompt, "system prompt");
693
+ const resolvedAppendPrompt = await resolvePromptInput(appendSystemPrompt, "append system prompt");
654
694
 
655
695
  // Load SYSTEM.md customization (prepended to prompt)
656
696
  const systemPromptCustomization = await loadSystemPromptFiles({ cwd: resolvedCwd });
@@ -697,7 +737,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
697
737
  (skillsSettings?.enabled !== false ? (await loadSkills({ ...skillsSettings, cwd: resolvedCwd })).skills : []);
698
738
 
699
739
  // Get git context
700
- const git = loadGitContext(resolvedCwd);
740
+ const git = await loadGitContext(resolvedCwd);
701
741
 
702
742
  // Filter skills to only include those with read tool
703
743
  const hasRead = tools?.has("read");
@@ -722,7 +762,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
722
762
  return renderPromptTemplate(systemPromptTemplate, {
723
763
  tools: toolNamesArray,
724
764
  toolDescriptions: toolDescriptionsArray,
725
- environment: getEnvironmentInfo(),
765
+ environment: await getEnvironmentInfo(),
726
766
  systemPromptCustomization: systemPromptCustomization ?? "",
727
767
  contextFiles,
728
768
  agentsMdSearch,
@@ -4,8 +4,8 @@
4
4
 
5
5
  import type { Api, Model } from "@oh-my-pi/pi-ai";
6
6
  import { completeSimple } from "@oh-my-pi/pi-ai";
7
+ import { logger } from "@oh-my-pi/pi-utils";
7
8
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
8
- import { logger } from "./logger";
9
9
  import type { ModelRegistry } from "./model-registry";
10
10
  import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver";
11
11
  import { renderPromptTemplate } from "./prompt-templates";
@@ -24,7 +24,7 @@ import askDescription from "../../prompts/tools/ask.md" with { type: "text" };
24
24
  import type { RenderResultOptions } from "../custom-tools/types";
25
25
  import { renderPromptTemplate } from "../prompt-templates";
26
26
  import type { ToolSession } from "./index";
27
- import { createToolUIKit } from "./render-utils";
27
+ import { ToolUIKit } from "./render-utils";
28
28
 
29
29
  // =============================================================================
30
30
  // Types
@@ -324,7 +324,7 @@ interface AskRenderArgs {
324
324
 
325
325
  export const askToolRenderer = {
326
326
  renderCall(args: AskRenderArgs, uiTheme: Theme): Component {
327
- const ui = createToolUIKit(uiTheme);
327
+ const ui = new ToolUIKit(uiTheme);
328
328
  const label = ui.title("Ask");
329
329
 
330
330
  // Multi-part questions
@@ -12,7 +12,7 @@ import { renderPromptTemplate } from "../prompt-templates";
12
12
  import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
13
13
  import type { ToolSession } from "./index";
14
14
  import { resolveToCwd } from "./path-utils";
15
- import { createToolUIKit } from "./render-utils";
15
+ import { ToolUIKit } from "./render-utils";
16
16
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
17
17
 
18
18
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
@@ -196,7 +196,7 @@ export const BASH_PREVIEW_LINES = 10;
196
196
 
197
197
  export const bashToolRenderer = {
198
198
  renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
199
- const ui = createToolUIKit(uiTheme);
199
+ const ui = new ToolUIKit(uiTheme);
200
200
  const command = args.command || uiTheme.format.ellipsis;
201
201
  const prompt = uiTheme.fg("accent", "$");
202
202
  const cwd = process.cwd();
@@ -231,7 +231,7 @@ export const bashToolRenderer = {
231
231
  options: RenderResultOptions & { renderContext?: BashRenderContext },
232
232
  uiTheme: Theme,
233
233
  ): Component {
234
- const ui = createToolUIKit(uiTheme);
234
+ const ui = new ToolUIKit(uiTheme);
235
235
  const { renderContext } = options;
236
236
  const details = result.details;
237
237
 
@@ -1,12 +1,12 @@
1
1
  import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
+ import { untilAborted } from "@oh-my-pi/pi-utils";
4
5
  import { Type } from "@sinclair/typebox";
5
6
  import type { Theme } from "../../modes/interactive/theme/theme";
6
7
  import calculatorDescription from "../../prompts/tools/calculator.md" with { type: "text" };
7
8
  import type { RenderResultOptions } from "../custom-tools/types";
8
9
  import { renderPromptTemplate } from "../prompt-templates";
9
- import { untilAborted } from "../utils";
10
10
  import type { ToolSession } from "./index";
11
11
  import {
12
12
  formatCount,
@@ -6,9 +6,9 @@
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
+ import { logger } from "@oh-my-pi/pi-utils";
9
10
  import type { TSchema } from "@sinclair/typebox";
10
11
  import type { CustomTool, CustomToolResult } from "../../custom-tools/types";
11
- import { logger } from "../../logger";
12
12
  import { callMCP } from "../../mcp/json-rpc";
13
13
  import type {
14
14
  ExaRenderDetails,
@@ -6,9 +6,9 @@
6
6
 
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
+ import { logger } from "@oh-my-pi/pi-utils";
9
10
  import type { Theme } from "../../../modes/interactive/theme/theme";
10
11
  import type { RenderResultOptions } from "../../custom-tools/types";
11
- import { logger } from "../../logger";
12
12
  import {
13
13
  formatCount,
14
14
  formatExpandHint,
@@ -3,6 +3,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
3
3
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
+ import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
6
7
  import type { Static } from "@sinclair/typebox";
7
8
  import { Type } from "@sinclair/typebox";
8
9
  import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
@@ -10,10 +11,9 @@ import findDescription from "../../prompts/tools/find.md" with { type: "text" };
10
11
  import { ensureTool } from "../../utils/tools-manager";
11
12
  import type { RenderResultOptions } from "../custom-tools/types";
12
13
  import { renderPromptTemplate } from "../prompt-templates";
13
- import { ScopeSignal, untilAborted } from "../utils";
14
14
  import type { ToolSession } from "./index";
15
15
  import { resolveToCwd } from "./path-utils";
16
- import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils";
16
+ import { PREVIEW_LIMITS, ToolUIKit } from "./render-utils";
17
17
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
18
18
 
19
19
  const findSchema = Type.Object({
@@ -63,51 +63,35 @@ export interface FindToolOptions {
63
63
  operations?: FindOperations;
64
64
  }
65
65
 
66
- async function captureCommandOutput(
67
- command: string,
68
- args: string[],
69
- signal?: AbortSignal,
70
- ): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> {
71
- const child = Bun.spawn([command, ...args], {
72
- stdin: "ignore",
73
- stdout: "pipe",
74
- stderr: "pipe",
75
- });
76
-
77
- using scope = new ScopeSignal(signal ? { signal } : undefined);
78
- scope.catch(() => {
79
- child.kill();
80
- });
81
-
82
- const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
83
- const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
84
- const stdoutDecoder = new TextDecoder();
85
- const stderrDecoder = new TextDecoder();
86
- let stdout = "";
87
- let stderr = "";
88
-
89
- await Promise.all([
90
- (async () => {
91
- while (true) {
92
- const { done, value } = await stdoutReader.read();
93
- if (done) break;
94
- stdout += stdoutDecoder.decode(value, { stream: true });
95
- }
96
- stdout += stdoutDecoder.decode();
97
- })(),
98
- (async () => {
99
- while (true) {
100
- const { done, value } = await stderrReader.read();
101
- if (done) break;
102
- stderr += stderrDecoder.decode(value, { stream: true });
103
- }
104
- stderr += stderrDecoder.decode();
105
- })(),
106
- ]);
66
+ export interface FdResult {
67
+ stdout: string;
68
+ stderr: string;
69
+ exitCode: number | null;
70
+ }
107
71
 
108
- const exitCode = await child.exited;
72
+ /**
73
+ * Run fd command and capture output.
74
+ *
75
+ * @throws Error with message "Operation aborted" if signal is aborted
76
+ */
77
+ export async function runFd(fdPath: string, args: string[], signal?: AbortSignal): Promise<FdResult> {
78
+ const child = ptree.cspawn([fdPath, ...args], { signal });
79
+
80
+ let stdout: string;
81
+ try {
82
+ stdout = await child.nothrow().text();
83
+ } catch (err) {
84
+ if (err instanceof ptree.Exception && err.aborted) {
85
+ throw new Error("Operation aborted");
86
+ }
87
+ throw err;
88
+ }
109
89
 
110
- return { stdout, stderr, exitCode, aborted: scope.aborted };
90
+ return {
91
+ stdout,
92
+ stderr: child.peekStderr(),
93
+ exitCode: child.exitCode,
94
+ };
111
95
  }
112
96
 
113
97
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
@@ -254,7 +238,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
254
238
  "--no-ignore",
255
239
  "--type",
256
240
  "f",
257
- "--name",
241
+ "--glob",
258
242
  ".gitignore",
259
243
  "--exclude",
260
244
  ".git",
@@ -263,24 +247,17 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
263
247
  "--absolute-path",
264
248
  searchPath,
265
249
  ];
266
- const { stdout: gitignoreStdout, aborted: gitignoreAborted } = await captureCommandOutput(
267
- fdPath,
268
- gitignoreArgs,
269
- signal,
270
- );
271
- if (gitignoreAborted) {
272
- throw new Error("Operation aborted");
273
- }
250
+ const { stdout: gitignoreStdout } = await runFd(fdPath, gitignoreArgs, signal);
274
251
  for (const rawLine of gitignoreStdout.split("\n")) {
275
252
  const file = rawLine.trim();
276
253
  if (!file) continue;
277
254
  gitignoreFiles.add(file);
278
255
  }
279
256
  } catch (err) {
280
- if (signal?.aborted) {
281
- throw err instanceof Error ? err : new Error("Operation aborted");
257
+ if (err instanceof Error && err.message === "Operation aborted") {
258
+ throw err;
282
259
  }
283
- // Ignore lookup errors
260
+ // Ignore other lookup errors
284
261
  }
285
262
 
286
263
  for (const gitignorePath of gitignoreFiles) {
@@ -291,20 +268,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
291
268
  args.push(effectivePattern, searchPath);
292
269
 
293
270
  // Run fd
294
- const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal);
295
-
296
- if (aborted) {
297
- throw new Error("Operation aborted");
298
- }
299
-
271
+ const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal);
300
272
  const output = stdout.trim();
301
273
 
302
- if (exitCode !== 0) {
303
- const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`;
304
- // fd returns non-zero for some errors but may still have partial output
305
- if (!output) {
306
- throw new Error(errorMsg);
307
- }
274
+ if (exitCode !== 0 && !output) {
275
+ throw new Error(stderr.trim() || `fd exited with code ${exitCode ?? -1}`);
308
276
  }
309
277
 
310
278
  if (!output) {
@@ -421,7 +389,7 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
421
389
  export const findToolRenderer = {
422
390
  inline: true,
423
391
  renderCall(args: FindRenderArgs, uiTheme: Theme): Component {
424
- const ui = createToolUIKit(uiTheme);
392
+ const ui = new ToolUIKit(uiTheme);
425
393
  const label = ui.title("Find");
426
394
  let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "*")}`;
427
395
 
@@ -442,7 +410,7 @@ export const findToolRenderer = {
442
410
  { expanded }: RenderResultOptions,
443
411
  uiTheme: Theme,
444
412
  ): Component {
445
- const ui = createToolUIKit(uiTheme);
413
+ const ui = new ToolUIKit(uiTheme);
446
414
  const details = result.details;
447
415
 
448
416
  if (result.isError || details?.error) {
@@ -1,13 +1,13 @@
1
1
  import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
+ import { untilAborted } from "@oh-my-pi/pi-utils";
4
5
  import { type Static, Type } from "@sinclair/typebox";
5
6
  import { nanoid } from "nanoid";
6
7
  import geminiImageDescription from "../../prompts/tools/gemini-image.md" with { type: "text" };
7
8
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
8
9
  import type { CustomTool } from "../custom-tools/types";
9
10
  import { renderPromptTemplate } from "../prompt-templates";
10
- import { untilAborted } from "../utils";
11
11
  import { resolveReadPath } from "./path-utils";
12
12
  import { getEnv } from "./web-search/auth";
13
13