@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.1

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/find.ts +7 -1
  62. package/src/core/tools/gemini-image.ts +361 -0
  63. package/src/core/tools/git.ts +216 -0
  64. package/src/core/tools/index.ts +28 -15
  65. package/src/core/tools/ls.ts +9 -2
  66. package/src/core/tools/lsp/config.ts +5 -4
  67. package/src/core/tools/lsp/index.ts +17 -12
  68. package/src/core/tools/lsp/render.ts +39 -47
  69. package/src/core/tools/read.ts +66 -29
  70. package/src/core/tools/render-utils.ts +268 -0
  71. package/src/core/tools/renderers.ts +243 -225
  72. package/src/core/tools/task/discovery.ts +2 -2
  73. package/src/core/tools/task/executor.ts +66 -58
  74. package/src/core/tools/task/index.ts +29 -10
  75. package/src/core/tools/task/model-resolver.ts +8 -13
  76. package/src/core/tools/task/omp-command.ts +24 -0
  77. package/src/core/tools/task/render.ts +37 -62
  78. package/src/core/tools/task/types.ts +3 -0
  79. package/src/core/tools/web-fetch.ts +29 -28
  80. package/src/core/tools/web-search/index.ts +6 -5
  81. package/src/core/tools/web-search/providers/exa.ts +6 -5
  82. package/src/core/tools/web-search/render.ts +66 -111
  83. package/src/core/voice-controller.ts +135 -0
  84. package/src/core/voice-supervisor.ts +1003 -0
  85. package/src/core/voice.ts +308 -0
  86. package/src/discovery/builtin.ts +75 -1
  87. package/src/discovery/claude.ts +47 -1
  88. package/src/discovery/codex.ts +54 -2
  89. package/src/discovery/gemini.ts +55 -2
  90. package/src/discovery/helpers.ts +100 -1
  91. package/src/discovery/index.ts +2 -0
  92. package/src/index.ts +14 -9
  93. package/src/lib/worktree/collapse.ts +179 -0
  94. package/src/lib/worktree/constants.ts +14 -0
  95. package/src/lib/worktree/errors.ts +23 -0
  96. package/src/lib/worktree/git.ts +110 -0
  97. package/src/lib/worktree/index.ts +23 -0
  98. package/src/lib/worktree/operations.ts +216 -0
  99. package/src/lib/worktree/session.ts +114 -0
  100. package/src/lib/worktree/stats.ts +67 -0
  101. package/src/main.ts +61 -37
  102. package/src/migrations.ts +37 -7
  103. package/src/modes/interactive/components/bash-execution.ts +6 -4
  104. package/src/modes/interactive/components/custom-editor.ts +55 -0
  105. package/src/modes/interactive/components/custom-message.ts +95 -0
  106. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  107. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  108. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  109. package/src/modes/interactive/components/extensions/types.ts +1 -0
  110. package/src/modes/interactive/components/footer.ts +324 -0
  111. package/src/modes/interactive/components/hook-selector.ts +3 -3
  112. package/src/modes/interactive/components/model-selector.ts +7 -6
  113. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  114. package/src/modes/interactive/components/settings-defs.ts +55 -6
  115. package/src/modes/interactive/components/status-line.ts +45 -37
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +643 -113
  118. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  119. package/src/modes/print-mode.ts +14 -72
  120. package/src/modes/rpc/rpc-client.ts +23 -9
  121. package/src/modes/rpc/rpc-mode.ts +137 -125
  122. package/src/modes/rpc/rpc-types.ts +46 -24
  123. package/src/prompts/task.md +1 -0
  124. package/src/prompts/tools/gemini-image.md +4 -0
  125. package/src/prompts/tools/git.md +9 -0
  126. package/src/prompts/voice-summary.md +12 -0
  127. package/src/utils/image-convert.ts +26 -0
  128. package/src/utils/image-resize.ts +215 -0
  129. package/src/utils/shell-snapshot.ts +22 -20
@@ -0,0 +1,215 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+
3
+ export interface ImageResizeOptions {
4
+ maxWidth?: number; // Default: 2000
5
+ maxHeight?: number; // Default: 2000
6
+ maxBytes?: number; // Default: 4.5MB (below Anthropic's 5MB limit)
7
+ jpegQuality?: number; // Default: 80
8
+ }
9
+
10
+ export interface ResizedImage {
11
+ data: string; // base64
12
+ mimeType: string;
13
+ originalWidth: number;
14
+ originalHeight: number;
15
+ width: number;
16
+ height: number;
17
+ wasResized: boolean;
18
+ }
19
+
20
+ // 4.5MB - provides headroom below Anthropic's 5MB limit
21
+ const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
22
+
23
+ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
24
+ maxWidth: 2000,
25
+ maxHeight: 2000,
26
+ maxBytes: DEFAULT_MAX_BYTES,
27
+ jpegQuality: 80,
28
+ };
29
+
30
+ /** Helper to pick the smaller of two buffers */
31
+ function pickSmaller(
32
+ a: { buffer: Buffer; mimeType: string },
33
+ b: { buffer: Buffer; mimeType: string },
34
+ ): { buffer: Buffer; mimeType: string } {
35
+ return a.buffer.length <= b.buffer.length ? a : b;
36
+ }
37
+
38
+ /**
39
+ * Resize an image to fit within the specified max dimensions and file size.
40
+ * Returns the original image if it already fits within the limits.
41
+ *
42
+ * Uses sharp for image processing. If sharp is not available (e.g., in some
43
+ * environments), returns the original image unchanged.
44
+ *
45
+ * Strategy for staying under maxBytes:
46
+ * 1. First resize to maxWidth/maxHeight
47
+ * 2. Try both PNG and JPEG formats, pick the smaller one
48
+ * 3. If still too large, try JPEG with decreasing quality
49
+ * 4. If still too large, progressively reduce dimensions
50
+ */
51
+ export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
52
+ const opts = { ...DEFAULT_OPTIONS, ...options };
53
+ const buffer = Buffer.from(img.data, "base64");
54
+
55
+ let sharp: typeof import("sharp") | undefined;
56
+ try {
57
+ sharp = (await import("sharp")).default;
58
+ } catch {
59
+ // Sharp not available - return original image
60
+ // We can't get dimensions without sharp, so return 0s
61
+ return {
62
+ data: img.data,
63
+ mimeType: img.mimeType,
64
+ originalWidth: 0,
65
+ originalHeight: 0,
66
+ width: 0,
67
+ height: 0,
68
+ wasResized: false,
69
+ };
70
+ }
71
+
72
+ const sharpImg = sharp(buffer);
73
+ const metadata = await sharpImg.metadata();
74
+
75
+ const originalWidth = metadata.width ?? 0;
76
+ const originalHeight = metadata.height ?? 0;
77
+ const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
78
+
79
+ // Check if already within all limits (dimensions AND size)
80
+ const originalSize = buffer.length;
81
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
82
+ return {
83
+ data: img.data,
84
+ mimeType: img.mimeType ?? `image/${format}`,
85
+ originalWidth,
86
+ originalHeight,
87
+ width: originalWidth,
88
+ height: originalHeight,
89
+ wasResized: false,
90
+ };
91
+ }
92
+
93
+ // Calculate initial dimensions respecting max limits
94
+ let targetWidth = originalWidth;
95
+ let targetHeight = originalHeight;
96
+
97
+ if (targetWidth > opts.maxWidth) {
98
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
99
+ targetWidth = opts.maxWidth;
100
+ }
101
+ if (targetHeight > opts.maxHeight) {
102
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
103
+ targetHeight = opts.maxHeight;
104
+ }
105
+
106
+ // Helper to resize and encode in both formats, returning the smaller one
107
+ async function tryBothFormats(
108
+ width: number,
109
+ height: number,
110
+ jpegQuality: number,
111
+ ): Promise<{ buffer: Buffer; mimeType: string }> {
112
+ const resized = await sharp!(buffer)
113
+ .resize(width, height, { fit: "inside", withoutEnlargement: true })
114
+ .toBuffer();
115
+
116
+ const [pngBuffer, jpegBuffer] = await Promise.all([
117
+ sharp!(resized).png({ compressionLevel: 9 }).toBuffer(),
118
+ sharp!(resized).jpeg({ quality: jpegQuality }).toBuffer(),
119
+ ]);
120
+
121
+ return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
122
+ }
123
+
124
+ // Try to produce an image under maxBytes
125
+ const qualitySteps = [85, 70, 55, 40];
126
+ const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
127
+
128
+ let best: { buffer: Buffer; mimeType: string };
129
+ let finalWidth = targetWidth;
130
+ let finalHeight = targetHeight;
131
+
132
+ // First attempt: resize to target dimensions, try both formats
133
+ best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
134
+
135
+ if (best.buffer.length <= opts.maxBytes) {
136
+ return {
137
+ data: best.buffer.toString("base64"),
138
+ mimeType: best.mimeType,
139
+ originalWidth,
140
+ originalHeight,
141
+ width: finalWidth,
142
+ height: finalHeight,
143
+ wasResized: true,
144
+ };
145
+ }
146
+
147
+ // Still too large - try JPEG with decreasing quality (and compare to PNG each time)
148
+ for (const quality of qualitySteps) {
149
+ best = await tryBothFormats(targetWidth, targetHeight, quality);
150
+
151
+ if (best.buffer.length <= opts.maxBytes) {
152
+ return {
153
+ data: best.buffer.toString("base64"),
154
+ mimeType: best.mimeType,
155
+ originalWidth,
156
+ originalHeight,
157
+ width: finalWidth,
158
+ height: finalHeight,
159
+ wasResized: true,
160
+ };
161
+ }
162
+ }
163
+
164
+ // Still too large - reduce dimensions progressively
165
+ for (const scale of scaleSteps) {
166
+ finalWidth = Math.round(targetWidth * scale);
167
+ finalHeight = Math.round(targetHeight * scale);
168
+
169
+ // Skip if dimensions are too small
170
+ if (finalWidth < 100 || finalHeight < 100) {
171
+ break;
172
+ }
173
+
174
+ for (const quality of qualitySteps) {
175
+ best = await tryBothFormats(finalWidth, finalHeight, quality);
176
+
177
+ if (best.buffer.length <= opts.maxBytes) {
178
+ return {
179
+ data: best.buffer.toString("base64"),
180
+ mimeType: best.mimeType,
181
+ originalWidth,
182
+ originalHeight,
183
+ width: finalWidth,
184
+ height: finalHeight,
185
+ wasResized: true,
186
+ };
187
+ }
188
+ }
189
+ }
190
+
191
+ // Last resort: return smallest version we produced even if over limit
192
+ // (the API will reject it, but at least we tried everything)
193
+ return {
194
+ data: best.buffer.toString("base64"),
195
+ mimeType: best.mimeType,
196
+ originalWidth,
197
+ originalHeight,
198
+ width: finalWidth,
199
+ height: finalHeight,
200
+ wasResized: true,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Format a dimension note for resized images.
206
+ * This helps the model understand the coordinate mapping.
207
+ */
208
+ export function formatDimensionNote(result: ResizedImage): string | undefined {
209
+ if (!result.wasResized) {
210
+ return undefined;
211
+ }
212
+
213
+ const scale = result.originalWidth / result.width;
214
+ return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
215
+ }
@@ -6,7 +6,6 @@
6
6
  * shell experience.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
10
9
  import { homedir, tmpdir } from "node:os";
11
10
  import { join } from "node:path";
12
11
 
@@ -28,9 +27,11 @@ function getShellConfigFile(shell: string): string {
28
27
  * This script sources the user's rc file and extracts functions, aliases, and options.
29
28
  * Matches Claude Code's snapshot generation logic.
30
29
  */
31
- function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): string {
32
- const hasRcFile = existsSync(rcFile);
30
+ async function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): Promise<string> {
31
+ const hasRcFile = await Bun.file(rcFile).exists();
33
32
  const isZsh = shell.includes("zsh");
33
+ const commonToolsRegex =
34
+ "^(ls|dir|vdir|cat|head|tail|less|more|grep|egrep|fgrep|rg|find|fd|locate|sed|awk|perl|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|pwd|readlink|stat|cut|sort|uniq|xargs|tee|tr|basename|dirname)$";
34
35
 
35
36
  // Escape the snapshot path for shell
36
37
  const escapedPath = snapshotPath.replace(/'/g, "'\\''");
@@ -42,7 +43,7 @@ echo "# Functions" >> "$SNAPSHOT_FILE"
42
43
  # Force autoload all functions first
43
44
  typeset -f > /dev/null 2>&1
44
45
  # Get user function names - filter system/private ones
45
- typeset +f 2>/dev/null | grep -vE '^(_|__)' | while read func; do
46
+ typeset +f 2>/dev/null | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
46
47
  typeset -f "$func" >> "$SNAPSHOT_FILE" 2>/dev/null
47
48
  done
48
49
  `
@@ -51,7 +52,7 @@ echo "# Functions" >> "$SNAPSHOT_FILE"
51
52
  # Force autoload all functions first
52
53
  declare -f > /dev/null 2>&1
53
54
  # Get user function names - filter system/private ones, use base64 for special chars
54
- declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | while read func; do
55
+ declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
55
56
  encoded_func=$(declare -f "$func" | base64)
56
57
  echo "eval \\"\\$(echo '$encoded_func' | base64 -d)\\" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"
57
58
  done
@@ -90,9 +91,9 @@ ${optionsScript}
90
91
  echo "# Aliases" >> "$SNAPSHOT_FILE"
91
92
  # Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors
92
93
  if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
93
- alias 2>/dev/null | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
94
+ alias 2>/dev/null | grep -v "='winpty " | grep -vE '^alias (${commonToolsRegex})=' | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
94
95
  else
95
- alias 2>/dev/null | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
96
+ alias 2>/dev/null | grep -vE '^alias (${commonToolsRegex})=' | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
96
97
  fi
97
98
 
98
99
  # Export PATH
@@ -115,7 +116,7 @@ export async function getOrCreateSnapshot(
115
116
  env: Record<string, string | undefined>,
116
117
  ): Promise<string | null> {
117
118
  // Return cached snapshot if valid
118
- if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) {
119
+ if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
119
120
  return cachedSnapshotPath;
120
121
  }
121
122
 
@@ -128,9 +129,8 @@ export async function getOrCreateSnapshot(
128
129
 
129
130
  // Create snapshot directory
130
131
  const snapshotDir = join(tmpdir(), "omp-shell-snapshots");
131
- try {
132
- mkdirSync(snapshotDir, { recursive: true });
133
- } catch {
132
+ const mkdirProc = Bun.spawnSync(["mkdir", "-p", snapshotDir]);
133
+ if (mkdirProc.exitCode !== 0) {
134
134
  return null;
135
135
  }
136
136
 
@@ -141,7 +141,7 @@ export async function getOrCreateSnapshot(
141
141
  const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`);
142
142
 
143
143
  // Generate and execute snapshot script
144
- const script = generateSnapshotScript(shell, snapshotPath, rcFile);
144
+ const script = await generateSnapshotScript(shell, snapshotPath, rcFile);
145
145
 
146
146
  try {
147
147
  const result = Bun.spawnSync([shell, "-l", "-c", script], {
@@ -152,7 +152,7 @@ export async function getOrCreateSnapshot(
152
152
  timeout: 10000, // 10 second timeout
153
153
  });
154
154
 
155
- if (result.exitCode === 0 && existsSync(snapshotPath)) {
155
+ if (result.exitCode === 0 && (await Bun.file(snapshotPath).exists())) {
156
156
  cachedSnapshotPath = snapshotPath;
157
157
  registerCleanup();
158
158
  return snapshotPath;
@@ -182,17 +182,19 @@ function registerCleanup(): void {
182
182
  if (cleanupRegistered) return;
183
183
  cleanupRegistered = true;
184
184
 
185
- const cleanup = () => {
186
- if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) {
185
+ const cleanup = async () => {
186
+ if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
187
187
  try {
188
- unlinkSync(cachedSnapshotPath);
188
+ Bun.spawnSync(["rm", cachedSnapshotPath]);
189
189
  } catch {
190
190
  // Ignore cleanup errors
191
191
  }
192
192
  }
193
193
  };
194
194
 
195
- process.on("exit", cleanup);
195
+ process.on("exit", () => {
196
+ cleanup();
197
+ });
196
198
  process.on("SIGINT", () => {
197
199
  cleanup();
198
200
  process.exit(130);
@@ -206,10 +208,10 @@ function registerCleanup(): void {
206
208
  /**
207
209
  * Clear the cached snapshot (for testing or forced refresh).
208
210
  */
209
- export function clearSnapshotCache(): void {
210
- if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) {
211
+ export async function clearSnapshotCache(): Promise<void> {
212
+ if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
211
213
  try {
212
- unlinkSync(cachedSnapshotPath);
214
+ Bun.spawnSync(["rm", cachedSnapshotPath]);
213
215
  } catch {
214
216
  // Ignore
215
217
  }