indusagi-coding-agent 0.1.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 (240) hide show
  1. package/CHANGELOG.md +2249 -0
  2. package/README.md +546 -0
  3. package/dist/cli/args.js +282 -0
  4. package/dist/cli/config-selector.js +30 -0
  5. package/dist/cli/file-processor.js +78 -0
  6. package/dist/cli/list-models.js +91 -0
  7. package/dist/cli/session-picker.js +31 -0
  8. package/dist/cli.js +10 -0
  9. package/dist/config.js +158 -0
  10. package/dist/core/agent-session.js +2097 -0
  11. package/dist/core/auth-storage.js +278 -0
  12. package/dist/core/bash-executor.js +211 -0
  13. package/dist/core/compaction/branch-summarization.js +241 -0
  14. package/dist/core/compaction/compaction.js +606 -0
  15. package/dist/core/compaction/index.js +6 -0
  16. package/dist/core/compaction/utils.js +137 -0
  17. package/dist/core/diagnostics.js +1 -0
  18. package/dist/core/event-bus.js +24 -0
  19. package/dist/core/exec.js +70 -0
  20. package/dist/core/export-html/ansi-to-html.js +248 -0
  21. package/dist/core/export-html/index.js +221 -0
  22. package/dist/core/export-html/template.css +905 -0
  23. package/dist/core/export-html/template.html +54 -0
  24. package/dist/core/export-html/template.js +1549 -0
  25. package/dist/core/export-html/tool-renderer.js +56 -0
  26. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  27. package/dist/core/export-html/vendor/marked.min.js +6 -0
  28. package/dist/core/extensions/index.js +8 -0
  29. package/dist/core/extensions/loader.js +395 -0
  30. package/dist/core/extensions/runner.js +499 -0
  31. package/dist/core/extensions/types.js +31 -0
  32. package/dist/core/extensions/wrapper.js +101 -0
  33. package/dist/core/footer-data-provider.js +133 -0
  34. package/dist/core/index.js +8 -0
  35. package/dist/core/keybindings.js +140 -0
  36. package/dist/core/messages.js +122 -0
  37. package/dist/core/model-registry.js +454 -0
  38. package/dist/core/model-resolver.js +309 -0
  39. package/dist/core/package-manager.js +1142 -0
  40. package/dist/core/prompt-templates.js +250 -0
  41. package/dist/core/resource-loader.js +569 -0
  42. package/dist/core/sdk.js +225 -0
  43. package/dist/core/session-manager.js +1078 -0
  44. package/dist/core/settings-manager.js +430 -0
  45. package/dist/core/skills.js +339 -0
  46. package/dist/core/system-prompt.js +136 -0
  47. package/dist/core/timings.js +24 -0
  48. package/dist/core/tools/bash.js +226 -0
  49. package/dist/core/tools/edit-diff.js +242 -0
  50. package/dist/core/tools/edit.js +145 -0
  51. package/dist/core/tools/find.js +205 -0
  52. package/dist/core/tools/grep.js +238 -0
  53. package/dist/core/tools/index.js +60 -0
  54. package/dist/core/tools/ls.js +117 -0
  55. package/dist/core/tools/path-utils.js +52 -0
  56. package/dist/core/tools/read.js +165 -0
  57. package/dist/core/tools/truncate.js +204 -0
  58. package/dist/core/tools/write.js +77 -0
  59. package/dist/index.js +41 -0
  60. package/dist/main.js +565 -0
  61. package/dist/migrations.js +260 -0
  62. package/dist/modes/index.js +7 -0
  63. package/dist/modes/interactive/components/armin.js +328 -0
  64. package/dist/modes/interactive/components/assistant-message.js +86 -0
  65. package/dist/modes/interactive/components/bash-execution.js +155 -0
  66. package/dist/modes/interactive/components/bordered-loader.js +47 -0
  67. package/dist/modes/interactive/components/branch-summary-message.js +41 -0
  68. package/dist/modes/interactive/components/compaction-summary-message.js +42 -0
  69. package/dist/modes/interactive/components/config-selector.js +458 -0
  70. package/dist/modes/interactive/components/countdown-timer.js +27 -0
  71. package/dist/modes/interactive/components/custom-editor.js +61 -0
  72. package/dist/modes/interactive/components/custom-message.js +80 -0
  73. package/dist/modes/interactive/components/diff.js +132 -0
  74. package/dist/modes/interactive/components/dynamic-border.js +19 -0
  75. package/dist/modes/interactive/components/extension-editor.js +96 -0
  76. package/dist/modes/interactive/components/extension-input.js +54 -0
  77. package/dist/modes/interactive/components/extension-selector.js +70 -0
  78. package/dist/modes/interactive/components/footer.js +213 -0
  79. package/dist/modes/interactive/components/index.js +31 -0
  80. package/dist/modes/interactive/components/keybinding-hints.js +60 -0
  81. package/dist/modes/interactive/components/login-dialog.js +138 -0
  82. package/dist/modes/interactive/components/model-selector.js +253 -0
  83. package/dist/modes/interactive/components/oauth-selector.js +91 -0
  84. package/dist/modes/interactive/components/scoped-models-selector.js +262 -0
  85. package/dist/modes/interactive/components/session-selector-search.js +145 -0
  86. package/dist/modes/interactive/components/session-selector.js +698 -0
  87. package/dist/modes/interactive/components/settings-selector.js +250 -0
  88. package/dist/modes/interactive/components/show-images-selector.js +33 -0
  89. package/dist/modes/interactive/components/skill-invocation-message.js +44 -0
  90. package/dist/modes/interactive/components/theme-selector.js +43 -0
  91. package/dist/modes/interactive/components/thinking-selector.js +45 -0
  92. package/dist/modes/interactive/components/tool-execution.js +608 -0
  93. package/dist/modes/interactive/components/tree-selector.js +892 -0
  94. package/dist/modes/interactive/components/user-message-selector.js +109 -0
  95. package/dist/modes/interactive/components/user-message.js +15 -0
  96. package/dist/modes/interactive/components/visual-truncate.js +32 -0
  97. package/dist/modes/interactive/interactive-mode.js +3576 -0
  98. package/dist/modes/interactive/theme/dark.json +85 -0
  99. package/dist/modes/interactive/theme/light.json +84 -0
  100. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  101. package/dist/modes/interactive/theme/theme.js +938 -0
  102. package/dist/modes/print-mode.js +96 -0
  103. package/dist/modes/rpc/rpc-client.js +390 -0
  104. package/dist/modes/rpc/rpc-mode.js +448 -0
  105. package/dist/modes/rpc/rpc-types.js +7 -0
  106. package/dist/utils/changelog.js +86 -0
  107. package/dist/utils/clipboard-image.js +116 -0
  108. package/dist/utils/clipboard.js +58 -0
  109. package/dist/utils/frontmatter.js +25 -0
  110. package/dist/utils/git.js +5 -0
  111. package/dist/utils/image-convert.js +34 -0
  112. package/dist/utils/image-resize.js +180 -0
  113. package/dist/utils/mime.js +25 -0
  114. package/dist/utils/photon.js +120 -0
  115. package/dist/utils/shell.js +164 -0
  116. package/dist/utils/sleep.js +16 -0
  117. package/dist/utils/tools-manager.js +186 -0
  118. package/docs/compaction.md +390 -0
  119. package/docs/custom-provider.md +538 -0
  120. package/docs/development.md +69 -0
  121. package/docs/extensions.md +1733 -0
  122. package/docs/images/doom-extension.png +0 -0
  123. package/docs/images/interactive-mode.png +0 -0
  124. package/docs/images/tree-view.png +0 -0
  125. package/docs/json.md +79 -0
  126. package/docs/keybindings.md +162 -0
  127. package/docs/models.md +193 -0
  128. package/docs/packages.md +163 -0
  129. package/docs/prompt-templates.md +67 -0
  130. package/docs/providers.md +147 -0
  131. package/docs/rpc.md +1048 -0
  132. package/docs/sdk.md +957 -0
  133. package/docs/session.md +412 -0
  134. package/docs/settings.md +216 -0
  135. package/docs/shell-aliases.md +13 -0
  136. package/docs/skills.md +226 -0
  137. package/docs/terminal-setup.md +65 -0
  138. package/docs/themes.md +295 -0
  139. package/docs/tree.md +219 -0
  140. package/docs/tui.md +887 -0
  141. package/docs/windows.md +17 -0
  142. package/examples/README.md +25 -0
  143. package/examples/extensions/README.md +192 -0
  144. package/examples/extensions/antigravity-image-gen.ts +414 -0
  145. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  146. package/examples/extensions/bookmark.ts +50 -0
  147. package/examples/extensions/claude-rules.ts +86 -0
  148. package/examples/extensions/confirm-destructive.ts +59 -0
  149. package/examples/extensions/custom-compaction.ts +115 -0
  150. package/examples/extensions/custom-footer.ts +65 -0
  151. package/examples/extensions/custom-header.ts +73 -0
  152. package/examples/extensions/custom-provider-anthropic/index.ts +605 -0
  153. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  154. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  155. package/examples/extensions/custom-provider-gitlab-duo/index.ts +350 -0
  156. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  157. package/examples/extensions/custom-provider-gitlab-duo/test.ts +83 -0
  158. package/examples/extensions/dirty-repo-guard.ts +56 -0
  159. package/examples/extensions/doom-overlay/README.md +46 -0
  160. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  161. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  162. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  163. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  164. package/examples/extensions/doom-overlay/doom-component.ts +133 -0
  165. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  166. package/examples/extensions/doom-overlay/doom-keys.ts +105 -0
  167. package/examples/extensions/doom-overlay/index.ts +74 -0
  168. package/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  169. package/examples/extensions/event-bus.ts +43 -0
  170. package/examples/extensions/file-trigger.ts +41 -0
  171. package/examples/extensions/git-checkpoint.ts +53 -0
  172. package/examples/extensions/handoff.ts +151 -0
  173. package/examples/extensions/hello.ts +25 -0
  174. package/examples/extensions/inline-bash.ts +94 -0
  175. package/examples/extensions/input-transform.ts +43 -0
  176. package/examples/extensions/interactive-shell.ts +196 -0
  177. package/examples/extensions/mac-system-theme.ts +47 -0
  178. package/examples/extensions/message-renderer.ts +60 -0
  179. package/examples/extensions/modal-editor.ts +86 -0
  180. package/examples/extensions/model-status.ts +31 -0
  181. package/examples/extensions/notify.ts +25 -0
  182. package/examples/extensions/overlay-qa-tests.ts +882 -0
  183. package/examples/extensions/overlay-test.ts +151 -0
  184. package/examples/extensions/permission-gate.ts +34 -0
  185. package/examples/extensions/pirate.ts +47 -0
  186. package/examples/extensions/plan-mode/README.md +65 -0
  187. package/examples/extensions/plan-mode/index.ts +341 -0
  188. package/examples/extensions/plan-mode/utils.ts +168 -0
  189. package/examples/extensions/preset.ts +399 -0
  190. package/examples/extensions/protected-paths.ts +30 -0
  191. package/examples/extensions/qna.ts +120 -0
  192. package/examples/extensions/question.ts +265 -0
  193. package/examples/extensions/questionnaire.ts +428 -0
  194. package/examples/extensions/rainbow-editor.ts +88 -0
  195. package/examples/extensions/sandbox/index.ts +318 -0
  196. package/examples/extensions/sandbox/package-lock.json +92 -0
  197. package/examples/extensions/sandbox/package.json +19 -0
  198. package/examples/extensions/send-user-message.ts +97 -0
  199. package/examples/extensions/session-name.ts +27 -0
  200. package/examples/extensions/shutdown-command.ts +63 -0
  201. package/examples/extensions/snake.ts +344 -0
  202. package/examples/extensions/space-invaders.ts +561 -0
  203. package/examples/extensions/ssh.ts +220 -0
  204. package/examples/extensions/status-line.ts +40 -0
  205. package/examples/extensions/subagent/README.md +172 -0
  206. package/examples/extensions/subagent/agents/planner.md +37 -0
  207. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  208. package/examples/extensions/subagent/agents/scout.md +50 -0
  209. package/examples/extensions/subagent/agents/worker.md +24 -0
  210. package/examples/extensions/subagent/agents.ts +127 -0
  211. package/examples/extensions/subagent/index.ts +964 -0
  212. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  213. package/examples/extensions/subagent/prompts/implement.md +10 -0
  214. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  215. package/examples/extensions/summarize.ts +196 -0
  216. package/examples/extensions/timed-confirm.ts +70 -0
  217. package/examples/extensions/todo.ts +300 -0
  218. package/examples/extensions/tool-override.ts +144 -0
  219. package/examples/extensions/tools.ts +147 -0
  220. package/examples/extensions/trigger-compact.ts +40 -0
  221. package/examples/extensions/truncated-tool.ts +193 -0
  222. package/examples/extensions/widget-placement.ts +17 -0
  223. package/examples/extensions/with-deps/index.ts +36 -0
  224. package/examples/extensions/with-deps/package-lock.json +31 -0
  225. package/examples/extensions/with-deps/package.json +22 -0
  226. package/examples/sdk/01-minimal.ts +22 -0
  227. package/examples/sdk/02-custom-model.ts +50 -0
  228. package/examples/sdk/03-custom-prompt.ts +55 -0
  229. package/examples/sdk/04-skills.ts +46 -0
  230. package/examples/sdk/05-tools.ts +56 -0
  231. package/examples/sdk/06-extensions.ts +88 -0
  232. package/examples/sdk/07-context-files.ts +40 -0
  233. package/examples/sdk/08-prompt-templates.ts +47 -0
  234. package/examples/sdk/09-api-keys-and-oauth.ts +48 -0
  235. package/examples/sdk/10-settings.ts +38 -0
  236. package/examples/sdk/11-sessions.ts +48 -0
  237. package/examples/sdk/12-full-control.ts +82 -0
  238. package/examples/sdk/13-codex-oauth.ts +37 -0
  239. package/examples/sdk/README.md +144 -0
  240. package/package.json +85 -0
@@ -0,0 +1,58 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { platform } from "os";
3
+ import { isWaylandSession } from "./clipboard-image.js";
4
+ export function copyToClipboard(text) {
5
+ const p = platform();
6
+ const options = { input: text, timeout: 5000 };
7
+ try {
8
+ if (p === "darwin") {
9
+ execSync("pbcopy", options);
10
+ }
11
+ else if (p === "win32") {
12
+ execSync("clip", options);
13
+ }
14
+ else {
15
+ // Linux - try wl-copy for Wayland, fall back to xclip/xsel for X11
16
+ const isWayland = isWaylandSession();
17
+ if (isWayland) {
18
+ try {
19
+ // Verify wl-copy exists (spawn errors are async and won't be caught)
20
+ execSync("which wl-copy", { stdio: "ignore" });
21
+ // wl-copy with execSync hangs due to fork behavior; use spawn instead
22
+ const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
23
+ proc.stdin.on("error", () => {
24
+ // Ignore EPIPE errors if wl-copy exits early
25
+ });
26
+ proc.stdin.write(text);
27
+ proc.stdin.end();
28
+ proc.unref();
29
+ }
30
+ catch {
31
+ // Fall back to xclip/xsel (works on XWayland)
32
+ try {
33
+ execSync("xclip -selection clipboard", options);
34
+ }
35
+ catch {
36
+ execSync("xsel --clipboard --input", options);
37
+ }
38
+ }
39
+ }
40
+ else {
41
+ try {
42
+ execSync("xclip -selection clipboard", options);
43
+ }
44
+ catch {
45
+ execSync("xsel --clipboard --input", options);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ catch (error) {
51
+ const msg = error instanceof Error ? error.message : String(error);
52
+ if (p === "linux") {
53
+ const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel";
54
+ throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`);
55
+ }
56
+ throw new Error(`Failed to copy to clipboard: ${msg}`);
57
+ }
58
+ }
@@ -0,0 +1,25 @@
1
+ import { parse } from "yaml";
2
+ const normalizeNewlines = (value) => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3
+ const extractFrontmatter = (content) => {
4
+ const normalized = normalizeNewlines(content);
5
+ if (!normalized.startsWith("---")) {
6
+ return { yamlString: null, body: normalized };
7
+ }
8
+ const endIndex = normalized.indexOf("\n---", 3);
9
+ if (endIndex === -1) {
10
+ return { yamlString: null, body: normalized };
11
+ }
12
+ return {
13
+ yamlString: normalized.slice(4, endIndex),
14
+ body: normalized.slice(endIndex + 4).trim(),
15
+ };
16
+ };
17
+ export const parseFrontmatter = (content) => {
18
+ const { yamlString, body } = extractFrontmatter(content);
19
+ if (!yamlString) {
20
+ return { frontmatter: {}, body };
21
+ }
22
+ const parsed = parse(yamlString);
23
+ return { frontmatter: (parsed ?? {}), body };
24
+ };
25
+ export const stripFrontmatter = (content) => parseFrontmatter(content).body;
@@ -0,0 +1,5 @@
1
+ const GIT_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
2
+ export function looksLikeGitUrl(source) {
3
+ const normalized = source.replace(/^https?:\/\//, "");
4
+ return GIT_HOSTS.some((host) => normalized.startsWith(`${host}/`));
5
+ }
@@ -0,0 +1,34 @@
1
+ import { loadPhoton } from "./photon.js";
2
+ /**
3
+ * Convert image to PNG format for terminal display.
4
+ * Kitty graphics protocol requires PNG format (f=100).
5
+ */
6
+ export async function convertToPng(base64Data, mimeType) {
7
+ // Already PNG, no conversion needed
8
+ if (mimeType === "image/png") {
9
+ return { data: base64Data, mimeType };
10
+ }
11
+ const photon = await loadPhoton();
12
+ if (!photon) {
13
+ // Photon not available, can't convert
14
+ return null;
15
+ }
16
+ try {
17
+ const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
18
+ const image = photon.PhotonImage.new_from_byteslice(bytes);
19
+ try {
20
+ const pngBuffer = image.get_bytes();
21
+ return {
22
+ data: Buffer.from(pngBuffer).toString("base64"),
23
+ mimeType: "image/png",
24
+ };
25
+ }
26
+ finally {
27
+ image.free();
28
+ }
29
+ }
30
+ catch {
31
+ // Conversion failed
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,180 @@
1
+ import { loadPhoton } from "./photon.js";
2
+ // 4.5MB - provides headroom below Anthropic's 5MB limit
3
+ const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
4
+ const DEFAULT_OPTIONS = {
5
+ maxWidth: 2000,
6
+ maxHeight: 2000,
7
+ maxBytes: DEFAULT_MAX_BYTES,
8
+ jpegQuality: 80,
9
+ };
10
+ /** Helper to pick the smaller of two buffers */
11
+ function pickSmaller(a, b) {
12
+ return a.buffer.length <= b.buffer.length ? a : b;
13
+ }
14
+ /**
15
+ * Resize an image to fit within the specified max dimensions and file size.
16
+ * Returns the original image if it already fits within the limits.
17
+ *
18
+ * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
19
+ * returns the original image unchanged.
20
+ *
21
+ * Strategy for staying under maxBytes:
22
+ * 1. First resize to maxWidth/maxHeight
23
+ * 2. Try both PNG and JPEG formats, pick the smaller one
24
+ * 3. If still too large, try JPEG with decreasing quality
25
+ * 4. If still too large, progressively reduce dimensions
26
+ */
27
+ export async function resizeImage(img, options) {
28
+ const opts = { ...DEFAULT_OPTIONS, ...options };
29
+ const inputBuffer = Buffer.from(img.data, "base64");
30
+ const photon = await loadPhoton();
31
+ if (!photon) {
32
+ // Photon not available, return original image
33
+ return {
34
+ data: img.data,
35
+ mimeType: img.mimeType,
36
+ originalWidth: 0,
37
+ originalHeight: 0,
38
+ width: 0,
39
+ height: 0,
40
+ wasResized: false,
41
+ };
42
+ }
43
+ let image;
44
+ try {
45
+ image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
46
+ const originalWidth = image.get_width();
47
+ const originalHeight = image.get_height();
48
+ const format = img.mimeType?.split("/")[1] ?? "png";
49
+ // Check if already within all limits (dimensions AND size)
50
+ const originalSize = inputBuffer.length;
51
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
52
+ return {
53
+ data: img.data,
54
+ mimeType: img.mimeType ?? `image/${format}`,
55
+ originalWidth,
56
+ originalHeight,
57
+ width: originalWidth,
58
+ height: originalHeight,
59
+ wasResized: false,
60
+ };
61
+ }
62
+ // Calculate initial dimensions respecting max limits
63
+ let targetWidth = originalWidth;
64
+ let targetHeight = originalHeight;
65
+ if (targetWidth > opts.maxWidth) {
66
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
67
+ targetWidth = opts.maxWidth;
68
+ }
69
+ if (targetHeight > opts.maxHeight) {
70
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
71
+ targetHeight = opts.maxHeight;
72
+ }
73
+ // Helper to resize and encode in both formats, returning the smaller one
74
+ function tryBothFormats(width, height, jpegQuality) {
75
+ const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
76
+ try {
77
+ const pngBuffer = resized.get_bytes();
78
+ const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
79
+ return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
80
+ }
81
+ finally {
82
+ resized.free();
83
+ }
84
+ }
85
+ // Try to produce an image under maxBytes
86
+ const qualitySteps = [85, 70, 55, 40];
87
+ const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
88
+ let best;
89
+ let finalWidth = targetWidth;
90
+ let finalHeight = targetHeight;
91
+ // First attempt: resize to target dimensions, try both formats
92
+ best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
93
+ if (best.buffer.length <= opts.maxBytes) {
94
+ return {
95
+ data: Buffer.from(best.buffer).toString("base64"),
96
+ mimeType: best.mimeType,
97
+ originalWidth,
98
+ originalHeight,
99
+ width: finalWidth,
100
+ height: finalHeight,
101
+ wasResized: true,
102
+ };
103
+ }
104
+ // Still too large - try JPEG with decreasing quality
105
+ for (const quality of qualitySteps) {
106
+ best = tryBothFormats(targetWidth, targetHeight, quality);
107
+ if (best.buffer.length <= opts.maxBytes) {
108
+ return {
109
+ data: Buffer.from(best.buffer).toString("base64"),
110
+ mimeType: best.mimeType,
111
+ originalWidth,
112
+ originalHeight,
113
+ width: finalWidth,
114
+ height: finalHeight,
115
+ wasResized: true,
116
+ };
117
+ }
118
+ }
119
+ // Still too large - reduce dimensions progressively
120
+ for (const scale of scaleSteps) {
121
+ finalWidth = Math.round(targetWidth * scale);
122
+ finalHeight = Math.round(targetHeight * scale);
123
+ if (finalWidth < 100 || finalHeight < 100) {
124
+ break;
125
+ }
126
+ for (const quality of qualitySteps) {
127
+ best = tryBothFormats(finalWidth, finalHeight, quality);
128
+ if (best.buffer.length <= opts.maxBytes) {
129
+ return {
130
+ data: Buffer.from(best.buffer).toString("base64"),
131
+ mimeType: best.mimeType,
132
+ originalWidth,
133
+ originalHeight,
134
+ width: finalWidth,
135
+ height: finalHeight,
136
+ wasResized: true,
137
+ };
138
+ }
139
+ }
140
+ }
141
+ // Last resort: return smallest version we produced
142
+ return {
143
+ data: Buffer.from(best.buffer).toString("base64"),
144
+ mimeType: best.mimeType,
145
+ originalWidth,
146
+ originalHeight,
147
+ width: finalWidth,
148
+ height: finalHeight,
149
+ wasResized: true,
150
+ };
151
+ }
152
+ catch {
153
+ // Failed to load image
154
+ return {
155
+ data: img.data,
156
+ mimeType: img.mimeType,
157
+ originalWidth: 0,
158
+ originalHeight: 0,
159
+ width: 0,
160
+ height: 0,
161
+ wasResized: false,
162
+ };
163
+ }
164
+ finally {
165
+ if (image) {
166
+ image.free();
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Format a dimension note for resized images.
172
+ * This helps the model understand the coordinate mapping.
173
+ */
174
+ export function formatDimensionNote(result) {
175
+ if (!result.wasResized) {
176
+ return undefined;
177
+ }
178
+ const scale = result.originalWidth / result.width;
179
+ 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.]`;
180
+ }
@@ -0,0 +1,25 @@
1
+ import { open } from "node:fs/promises";
2
+ import { fileTypeFromBuffer } from "file-type";
3
+ const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
4
+ const FILE_TYPE_SNIFF_BYTES = 4100;
5
+ export async function detectSupportedImageMimeTypeFromFile(filePath) {
6
+ const fileHandle = await open(filePath, "r");
7
+ try {
8
+ const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
9
+ const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
10
+ if (bytesRead === 0) {
11
+ return null;
12
+ }
13
+ const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead));
14
+ if (!fileType) {
15
+ return null;
16
+ }
17
+ if (!IMAGE_MIME_TYPES.has(fileType.mime)) {
18
+ return null;
19
+ }
20
+ return fileType.mime;
21
+ }
22
+ finally {
23
+ await fileHandle.close();
24
+ }
25
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Photon image processing wrapper.
3
+ *
4
+ * This module provides a unified interface to @silvia-odwyer/photon-node that works in:
5
+ * 1. Node.js (development, npm run build)
6
+ * 2. Bun compiled binaries (standalone distribution)
7
+ *
8
+ * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
9
+ * which bakes the build machine's absolute path into Bun compiled binaries.
10
+ *
11
+ * Solution:
12
+ * 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
13
+ * 2. Copy photon_rs_bg.wasm next to the executable in build:binary
14
+ */
15
+ import { createRequire } from "module";
16
+ import * as path from "path";
17
+ import { fileURLToPath } from "url";
18
+ const require = createRequire(import.meta.url);
19
+ const fs = require("fs");
20
+ const WASM_FILENAME = "photon_rs_bg.wasm";
21
+ // Lazy-loaded photon module
22
+ let photonModule = null;
23
+ let loadPromise = null;
24
+ function pathOrNull(file) {
25
+ if (typeof file === "string") {
26
+ return file;
27
+ }
28
+ if (file instanceof URL) {
29
+ return fileURLToPath(file);
30
+ }
31
+ return null;
32
+ }
33
+ function getFallbackWasmPaths() {
34
+ const execDir = path.dirname(process.execPath);
35
+ return [
36
+ path.join(execDir, WASM_FILENAME),
37
+ path.join(execDir, "photon", WASM_FILENAME),
38
+ path.join(process.cwd(), WASM_FILENAME),
39
+ ];
40
+ }
41
+ function patchPhotonWasmRead() {
42
+ const originalReadFileSync = fs.readFileSync.bind(fs);
43
+ const fallbackPaths = getFallbackWasmPaths();
44
+ const mutableFs = fs;
45
+ const patchedReadFileSync = ((...args) => {
46
+ const [file, options] = args;
47
+ const resolvedPath = pathOrNull(file);
48
+ if (resolvedPath?.endsWith(WASM_FILENAME)) {
49
+ try {
50
+ return originalReadFileSync(...args);
51
+ }
52
+ catch (error) {
53
+ const err = error;
54
+ if (err?.code && err.code !== "ENOENT") {
55
+ throw error;
56
+ }
57
+ for (const fallbackPath of fallbackPaths) {
58
+ if (!fs.existsSync(fallbackPath)) {
59
+ continue;
60
+ }
61
+ if (options === undefined) {
62
+ return originalReadFileSync(fallbackPath);
63
+ }
64
+ return originalReadFileSync(fallbackPath, options);
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ return originalReadFileSync(...args);
70
+ });
71
+ try {
72
+ mutableFs.readFileSync = patchedReadFileSync;
73
+ }
74
+ catch {
75
+ Object.defineProperty(fs, "readFileSync", {
76
+ value: patchedReadFileSync,
77
+ writable: true,
78
+ configurable: true,
79
+ });
80
+ }
81
+ return () => {
82
+ try {
83
+ mutableFs.readFileSync = originalReadFileSync;
84
+ }
85
+ catch {
86
+ Object.defineProperty(fs, "readFileSync", {
87
+ value: originalReadFileSync,
88
+ writable: true,
89
+ configurable: true,
90
+ });
91
+ }
92
+ };
93
+ }
94
+ /**
95
+ * Load the photon module asynchronously.
96
+ * Returns cached module on subsequent calls.
97
+ */
98
+ export async function loadPhoton() {
99
+ if (photonModule) {
100
+ return photonModule;
101
+ }
102
+ if (loadPromise) {
103
+ return loadPromise;
104
+ }
105
+ loadPromise = (async () => {
106
+ const restoreReadFileSync = patchPhotonWasmRead();
107
+ try {
108
+ photonModule = await import("@silvia-odwyer/photon-node");
109
+ return photonModule;
110
+ }
111
+ catch {
112
+ photonModule = null;
113
+ return photonModule;
114
+ }
115
+ finally {
116
+ restoreReadFileSync();
117
+ }
118
+ })();
119
+ return loadPromise;
120
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync } from "node:fs";
2
+ import { delimiter } from "node:path";
3
+ import { spawn, spawnSync } from "child_process";
4
+ import { getBinDir, getSettingsPath } from "../config.js";
5
+ import { SettingsManager } from "../core/settings-manager.js";
6
+ let cachedShellConfig = null;
7
+ /**
8
+ * Find bash executable on PATH (Windows)
9
+ */
10
+ function findBashOnPath() {
11
+ try {
12
+ const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
13
+ if (result.status === 0 && result.stdout) {
14
+ const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
15
+ if (firstMatch && existsSync(firstMatch)) {
16
+ return firstMatch;
17
+ }
18
+ }
19
+ }
20
+ catch {
21
+ // Ignore errors
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * Get shell configuration based on platform.
27
+ * Resolution order:
28
+ * 1. User-specified shellPath in settings.json
29
+ * 2. On Windows: Git Bash in known locations, then bash on PATH
30
+ * 3. On Unix: /bin/bash
31
+ * 4. Fallback: sh
32
+ */
33
+ export function getShellConfig() {
34
+ if (cachedShellConfig) {
35
+ return cachedShellConfig;
36
+ }
37
+ const settings = SettingsManager.create();
38
+ const customShellPath = settings.getShellPath();
39
+ // 1. Check user-specified shell path
40
+ if (customShellPath) {
41
+ if (existsSync(customShellPath)) {
42
+ cachedShellConfig = { shell: customShellPath, args: ["-c"] };
43
+ return cachedShellConfig;
44
+ }
45
+ throw new Error(`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${getSettingsPath()}`);
46
+ }
47
+ if (process.platform === "win32") {
48
+ // 2. Try Git Bash in known locations
49
+ const paths = [];
50
+ const programFiles = process.env.ProgramFiles;
51
+ if (programFiles) {
52
+ paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
53
+ }
54
+ const programFilesX86 = process.env["ProgramFiles(x86)"];
55
+ if (programFilesX86) {
56
+ paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
57
+ }
58
+ for (const path of paths) {
59
+ if (existsSync(path)) {
60
+ cachedShellConfig = { shell: path, args: ["-c"] };
61
+ return cachedShellConfig;
62
+ }
63
+ }
64
+ // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
65
+ const bashOnPath = findBashOnPath();
66
+ if (bashOnPath) {
67
+ cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
68
+ return cachedShellConfig;
69
+ }
70
+ throw new Error(`No bash shell found. Options:\n` +
71
+ ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
72
+ ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
73
+ ` 3. Set shellPath in ${getSettingsPath()}\n\n` +
74
+ `Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`);
75
+ }
76
+ // Unix: prefer bash over sh
77
+ if (existsSync("/bin/bash")) {
78
+ cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
79
+ return cachedShellConfig;
80
+ }
81
+ cachedShellConfig = { shell: "sh", args: ["-c"] };
82
+ return cachedShellConfig;
83
+ }
84
+ export function getShellEnv() {
85
+ const binDir = getBinDir();
86
+ const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
87
+ const currentPath = process.env[pathKey] ?? "";
88
+ const pathEntries = currentPath.split(delimiter).filter(Boolean);
89
+ const hasBinDir = pathEntries.includes(binDir);
90
+ const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
91
+ return {
92
+ ...process.env,
93
+ [pathKey]: updatedPath,
94
+ };
95
+ }
96
+ /**
97
+ * Sanitize binary output for display/storage.
98
+ * Removes characters that crash string-width or cause display issues:
99
+ * - Control characters (except tab, newline, carriage return)
100
+ * - Lone surrogates
101
+ * - Unicode Format characters (crash string-width due to a bug)
102
+ * - Characters with undefined code points
103
+ */
104
+ export function sanitizeBinaryOutput(str) {
105
+ // Use Array.from to properly iterate over code points (not code units)
106
+ // This handles surrogate pairs correctly and catches edge cases where
107
+ // codePointAt() might return undefined
108
+ return Array.from(str)
109
+ .filter((char) => {
110
+ // Filter out characters that cause string-width to crash
111
+ // This includes:
112
+ // - Unicode format characters
113
+ // - Lone surrogates (already filtered by Array.from)
114
+ // - Control chars except \t \n \r
115
+ // - Characters with undefined code points
116
+ const code = char.codePointAt(0);
117
+ // Skip if code point is undefined (edge case with invalid strings)
118
+ if (code === undefined)
119
+ return false;
120
+ // Allow tab, newline, carriage return
121
+ if (code === 0x09 || code === 0x0a || code === 0x0d)
122
+ return true;
123
+ // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
124
+ if (code <= 0x1f)
125
+ return false;
126
+ // Filter out Unicode format characters
127
+ if (code >= 0xfff9 && code <= 0xfffb)
128
+ return false;
129
+ return true;
130
+ })
131
+ .join("");
132
+ }
133
+ /**
134
+ * Kill a process and all its children (cross-platform)
135
+ */
136
+ export function killProcessTree(pid) {
137
+ if (process.platform === "win32") {
138
+ // Use taskkill on Windows to kill process tree
139
+ try {
140
+ spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
141
+ stdio: "ignore",
142
+ detached: true,
143
+ });
144
+ }
145
+ catch {
146
+ // Ignore errors if taskkill fails
147
+ }
148
+ }
149
+ else {
150
+ // Use SIGKILL on Unix/Linux/Mac
151
+ try {
152
+ process.kill(-pid, "SIGKILL");
153
+ }
154
+ catch {
155
+ // Fallback to killing just the child if process group kill fails
156
+ try {
157
+ process.kill(pid, "SIGKILL");
158
+ }
159
+ catch {
160
+ // Process already dead
161
+ }
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Sleep helper that respects abort signal.
3
+ */
4
+ export function sleep(ms, signal) {
5
+ return new Promise((resolve, reject) => {
6
+ if (signal?.aborted) {
7
+ reject(new Error("Aborted"));
8
+ return;
9
+ }
10
+ const timeout = setTimeout(resolve, ms);
11
+ signal?.addEventListener("abort", () => {
12
+ clearTimeout(timeout);
13
+ reject(new Error("Aborted"));
14
+ });
15
+ });
16
+ }