@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -75,12 +75,31 @@ const defaultDeps: ResourceGcDeps = {
75
75
 
76
76
  // ── Controller state (process-global; tabs/browsers are module-global too) ──────────────────
77
77
  const activeSessions = new Map<string, Settings>();
78
- let timer: ReturnType<typeof setTimeout> | null = null;
78
+
79
+ interface ScheduleOwner {
80
+ generation: number;
81
+ token: number;
82
+ }
83
+
84
+ interface DeferredSchedule {
85
+ generation: number;
86
+ deadline: number;
87
+ }
88
+
89
+ interface WorkOwner {
90
+ generation: number;
91
+ source: "timer" | "external";
92
+ }
93
+
94
+ let pendingTimer: NodeJS.Timeout | null = null;
95
+ let pendingDeadline: number | null = null;
96
+ let pendingOwner: ScheduleOwner | null = null;
97
+ let deferredSchedule: DeferredSchedule | null = null;
98
+ let inProgressOwner: WorkOwner | null = null;
79
99
  let stopped = false;
80
- // Bumped on every stop so an in-flight tick from a previous schedule cannot reschedule after a
81
- // stop+re-register and leak a duplicate timer.
82
100
  let timerGeneration = 0;
83
- let inProgress = false;
101
+ let nextTimerToken = 0;
102
+ let schedulerNow = (): number => performance.now();
84
103
  let rssWarningActive = false;
85
104
  let lastScreenshotScanAt = 0;
86
105
  let deps: ResourceGcDeps = defaultDeps;
@@ -96,8 +115,20 @@ export interface ResourceGcRegistration {
96
115
  * session unregisters.
97
116
  */
98
117
  export function registerResourceGcSession(reg: ResourceGcRegistration): () => void {
118
+ const isNewSession = !activeSessions.has(reg.sessionId);
99
119
  activeSessions.set(reg.sessionId, reg.settings);
100
- ensureTimerStarted();
120
+ stopped = false;
121
+ if (isNewSession) {
122
+ const deadline = schedulerNow() + resolveSweepIntervalMs(reg.settings);
123
+ if (
124
+ inProgressOwner?.generation === timerGeneration &&
125
+ (pendingDeadline === null || pendingDeadline <= deadline)
126
+ ) {
127
+ deferSchedule(timerGeneration, deadline);
128
+ } else {
129
+ requestSchedule(deadline);
130
+ }
131
+ }
101
132
  let unregistered = false;
102
133
  return () => {
103
134
  if (unregistered) return;
@@ -113,51 +144,78 @@ function currentSweepIntervalMs(): number {
113
144
  return Number.isFinite(min) ? min : DEFAULT_SWEEP_INTERVAL_MS;
114
145
  }
115
146
 
116
- function ensureTimerStarted(): void {
117
- if (timer) return;
118
- stopped = false;
119
- scheduleNextSweep();
147
+ function clearPendingSchedule(): void {
148
+ if (pendingTimer) clearTimeout(pendingTimer);
149
+ pendingTimer = null;
150
+ pendingDeadline = null;
151
+ pendingOwner = null;
120
152
  }
121
153
 
122
- // Recursive setTimeout (not setInterval) so the cadence is recomputed every cycle and later
123
- // session register/unregister changes to resourceGc.sweepIntervalMs are honored live.
124
- function scheduleNextSweep(): void {
125
- if (stopped || activeSessions.size === 0) {
126
- timer = null;
154
+ function requestSchedule(deadline: number): void {
155
+ if (stopped || activeSessions.size === 0) return;
156
+ if (pendingDeadline !== null && pendingDeadline <= deadline) return;
157
+ clearPendingSchedule();
158
+ const owner = { generation: timerGeneration, token: ++nextTimerToken };
159
+ pendingDeadline = deadline;
160
+ pendingOwner = owner;
161
+ pendingTimer = setTimeout(
162
+ () => {
163
+ void handleTimerCallback(owner, deadline);
164
+ },
165
+ Math.max(0, deadline - schedulerNow()),
166
+ );
167
+ pendingTimer.unref?.();
168
+ }
169
+
170
+ function deferSchedule(generation: number, deadline: number): void {
171
+ if (generation !== timerGeneration) return;
172
+ if (deferredSchedule?.generation === generation) {
173
+ deferredSchedule.deadline = Math.min(deferredSchedule.deadline, deadline);
127
174
  return;
128
175
  }
129
- const generation = timerGeneration;
130
- timer = setTimeout(() => {
131
- void tickAndReschedule(generation);
132
- }, currentSweepIntervalMs());
133
- timer.unref?.();
176
+ deferredSchedule = { generation, deadline };
134
177
  }
135
178
 
136
- async function tickAndReschedule(generation: number): Promise<void> {
137
- await runTick();
138
- // A stop (and possible re-register) happened during the tick: a newer cycle owns the timer now.
139
- if (generation !== timerGeneration) return;
140
- scheduleNextSweep();
179
+ async function handleTimerCallback(owner: ScheduleOwner, deadline: number): Promise<void> {
180
+ if (pendingOwner?.generation !== owner.generation || pendingOwner.token !== owner.token) return;
181
+ pendingTimer = null;
182
+ pendingDeadline = null;
183
+ pendingOwner = null;
184
+ if (inProgressOwner) {
185
+ deferSchedule(owner.generation, deadline);
186
+ return;
187
+ }
188
+ await runTick(owner.generation, "timer");
189
+ }
190
+
191
+ function reconcileCurrentSchedule(): void {
192
+ if (stopped || activeSessions.size === 0 || inProgressOwner) return;
193
+ const normalDeadline = schedulerNow() + currentSweepIntervalMs();
194
+ const deferredDeadline = deferredSchedule?.generation === timerGeneration ? deferredSchedule.deadline : null;
195
+ if (deferredDeadline !== null) deferredSchedule = null;
196
+ requestSchedule(deferredDeadline === null ? normalDeadline : Math.min(deferredDeadline, normalDeadline));
141
197
  }
142
198
 
143
199
  function stopTimer(): void {
144
200
  stopped = true;
145
201
  timerGeneration++;
146
- if (timer) {
147
- clearTimeout(timer);
148
- timer = null;
149
- }
202
+ clearPendingSchedule();
203
+ deferredSchedule = null;
150
204
  }
151
205
 
152
- async function runTick(): Promise<void> {
153
- if (inProgress) return;
154
- inProgress = true;
206
+ async function runTick(generation = timerGeneration, source: WorkOwner["source"] = "external"): Promise<void> {
207
+ if (inProgressOwner || activeSessions.size === 0) return;
208
+ const owner: WorkOwner = { generation, source };
209
+ inProgressOwner = owner;
155
210
  try {
156
211
  await sweepOnce(deps);
157
212
  } catch (err) {
158
213
  logger.debug("resource GC sweep failed", { error: (err as Error).message });
159
214
  } finally {
160
- inProgress = false;
215
+ if (inProgressOwner === owner) inProgressOwner = null;
216
+ // A stale completion only releases its own work lock. Once that lock is released, current
217
+ // generation demand (which may have deferred behind it) can be reconciled independently.
218
+ reconcileCurrentSchedule();
161
219
  }
162
220
  }
163
221
 
@@ -268,24 +326,55 @@ export function __setResourceGcDepsForTest(overrides: Partial<ResourceGcDeps>):
268
326
  deps = { ...defaultDeps, ...overrides };
269
327
  }
270
328
 
329
+ export function __setResourceGcSchedulerNowForTest(now: () => number): void {
330
+ schedulerNow = now;
331
+ }
332
+
271
333
  export async function __runResourceGcTickForTest(): Promise<void> {
272
334
  await runTick();
273
335
  }
274
336
 
337
+ export async function __runResourceGcTimerCallbackForTest(
338
+ owner: { generation: number; token: number },
339
+ deadline: number,
340
+ ): Promise<void> {
341
+ await handleTimerCallback(owner, deadline);
342
+ }
343
+
275
344
  export function __getResourceGcStateForTest(): {
276
345
  timerActive: boolean;
277
346
  sessionCount: number;
278
347
  rssWarningActive: boolean;
279
348
  inProgress: boolean;
349
+ generation: number;
350
+ pendingDeadline: number | null;
351
+ pendingOwner: { generation: number; token: number } | null;
352
+ deferredDeadline: number | null;
353
+ deferredGeneration: number | null;
354
+ activeGeneration: number | null;
280
355
  } {
281
- return { timerActive: timer !== null, sessionCount: activeSessions.size, rssWarningActive, inProgress };
356
+ return {
357
+ timerActive: pendingTimer !== null,
358
+ sessionCount: activeSessions.size,
359
+ rssWarningActive,
360
+ inProgress: inProgressOwner !== null,
361
+ generation: timerGeneration,
362
+ pendingDeadline,
363
+ pendingOwner: pendingOwner ? { ...pendingOwner } : null,
364
+ deferredDeadline: deferredSchedule?.deadline ?? null,
365
+ deferredGeneration: deferredSchedule?.generation ?? null,
366
+ activeGeneration: inProgressOwner?.generation ?? null,
367
+ };
282
368
  }
283
369
 
284
370
  export function __resetResourceGcForTest(): void {
285
371
  stopTimer();
286
372
  activeSessions.clear();
287
- inProgress = false;
373
+ inProgressOwner = null;
374
+ deferredSchedule = null;
288
375
  rssWarningActive = false;
289
376
  lastScreenshotScanAt = 0;
377
+ nextTimerToken = 0;
378
+ schedulerNow = (): number => performance.now();
290
379
  deps = defaultDeps;
291
380
  }
@@ -17,6 +17,10 @@ const MAX_LIST_LIMIT = 50;
17
17
  const RECEIPT_PREVIEW_WIDTH = 280;
18
18
  const PREVIEW_WIDTH = 2_000;
19
19
  const FULL_PREVIEW_WIDTH = 12_000;
20
+ const RECEIPT_PREVIEW_BYTES = 1_024;
21
+ const PREVIEW_BYTES = 8_192;
22
+ const FULL_PREVIEW_BYTES = 49_152;
23
+
20
24
  const STEER_QUEUED_GUIDANCE =
21
25
  "The steer message is queued for the subagent's next steering boundary and has not necessarily taken effect yet.";
22
26
 
@@ -783,7 +787,40 @@ function isSubagentJob(job: AsyncJob): boolean {
783
787
  }
784
788
 
785
789
  function sanitizeText(text: string, width: number): string {
786
- return truncateToWidth(replaceTabs(text), width, Ellipsis.Unicode);
790
+ return capCodePointsAndBytes(
791
+ truncateToWidth(replaceTabs(text), width, Ellipsis.Unicode),
792
+ width,
793
+ previewByteCap(width),
794
+ );
795
+ }
796
+
797
+ function previewByteCap(width: number): number {
798
+ return width === FULL_PREVIEW_WIDTH
799
+ ? FULL_PREVIEW_BYTES
800
+ : width === PREVIEW_WIDTH
801
+ ? PREVIEW_BYTES
802
+ : RECEIPT_PREVIEW_BYTES;
803
+ }
804
+
805
+ export function capCodePointsAndBytes(text: string, maxCodePoints: number, maxBytes: number): string {
806
+ const ellipsis = "…";
807
+ const collapsed = text.replace(/…{2,}$/u, ellipsis);
808
+ const codePoints = [...collapsed];
809
+ if (codePoints.length <= maxCodePoints && Buffer.byteLength(collapsed) <= maxBytes) return collapsed;
810
+
811
+ const source = collapsed.endsWith(ellipsis) ? collapsed.slice(0, -ellipsis.length) : collapsed;
812
+ const ellipsisBytes = Buffer.byteLength(ellipsis);
813
+ let preview = "";
814
+ let previewBytes = 0;
815
+ let previewCodePoints = 0;
816
+ for (const codePoint of source) {
817
+ const codePointBytes = Buffer.byteLength(codePoint);
818
+ if (previewCodePoints + 1 + 1 > maxCodePoints || previewBytes + codePointBytes + ellipsisBytes > maxBytes) break;
819
+ preview += codePoint;
820
+ previewBytes += codePointBytes;
821
+ previewCodePoints++;
822
+ }
823
+ return `${preview}${ellipsis}`;
787
824
  }
788
825
 
789
826
  function previewJobOutput(
@@ -799,7 +836,11 @@ function previewJobOutput(
799
836
  const width =
800
837
  verbosity === "full" ? FULL_PREVIEW_WIDTH : verbosity === "preview" ? PREVIEW_WIDTH : RECEIPT_PREVIEW_WIDTH;
801
838
  const normalized = replaceTabs(source.text);
802
- const preview = truncateToWidth(normalized, width, Ellipsis.Unicode);
839
+ const preview = capCodePointsAndBytes(
840
+ truncateToWidth(normalized, width, Ellipsis.Unicode),
841
+ width,
842
+ previewByteCap(width),
843
+ );
803
844
  return { type: source.type, preview, truncated: preview !== normalized };
804
845
  }
805
846
 
@@ -160,11 +160,11 @@ async function resolveMentionPath(
160
160
  }
161
161
 
162
162
  /**
163
- * Default inline cap for auto-read file mentions, deliberately below the
164
- * read-tool cap (`DEFAULT_MAX_BYTES`, 50KB) so an incidental `@path` mention
165
- * injects a smaller snippet than an explicit `read`. Overridable per call.
163
+ * Default inline cap for auto-read file mentions, aligned with the read-tool's
164
+ * 10 KiB bare-read receipt so an incidental `@path` mention injects a bounded
165
+ * snippet. Overridable per call.
166
166
  */
167
- export const DEFAULT_FILE_MENTION_INLINE_BYTES = 20 * 1024;
167
+ export const DEFAULT_FILE_MENTION_INLINE_BYTES = 10 * 1024;
168
168
 
169
169
  function buildTextOutput(textContent: string, maxInlineBytes?: number): { output: string; lineCount: number } {
170
170
  const cap = maxInlineBytes && maxInlineBytes > 0 ? maxInlineBytes : DEFAULT_FILE_MENTION_INLINE_BYTES;
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import type { ImageContent } from "@gajae-code/ai";
3
3
  import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@gajae-code/utils";
4
4
  import { resolveReadPath } from "../tools/path-utils";
5
- import { formatDimensionNote, resizeImage } from "./image-resize";
5
+ import { formatDimensionNote, resizeImageBuffer } from "./image-resize";
6
6
 
7
7
  export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
8
8
  export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
@@ -14,6 +14,26 @@ export interface LoadImageInputOptions {
14
14
  maxBytes?: number;
15
15
  resolvedPath?: string;
16
16
  detectedMimeType?: string;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface LoadImageInputBytesOptions {
21
+ inputBuffer: Buffer;
22
+ resolvedPath: string;
23
+ mimeType: string;
24
+ autoResize: boolean;
25
+ maxBytes?: number;
26
+ maxOutputBytes?: number;
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ export interface TransformedImageInput {
31
+ resolvedPath: string;
32
+ mimeType: string;
33
+ buffer: Uint8Array;
34
+ textNote: string;
35
+ dimensionNote?: string;
36
+ bytes: number;
17
37
  }
18
38
 
19
39
  export interface LoadedImageInput {
@@ -38,9 +58,7 @@ export class ImageInputTooLargeError extends Error {
38
58
  }
39
59
 
40
60
  export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
41
- if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
42
- return image;
43
- }
61
+ if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) return image;
44
62
  try {
45
63
  const bytes = Buffer.from(image.data, "base64");
46
64
  const data = await new Bun.Image(bytes).png().toBase64();
@@ -50,53 +68,86 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise<Im
50
68
  }
51
69
  }
52
70
 
53
- export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
71
+ /** Transform trusted source bytes without materializing a base64 payload. */
72
+ export async function transformImageInputBytes(options: LoadImageInputBytesOptions): Promise<TransformedImageInput> {
54
73
  const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
55
- const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
56
- const metadata = options.detectedMimeType
57
- ? { mimeType: options.detectedMimeType }
58
- : await readImageMetadata(resolvedPath);
59
- const mimeType = metadata?.mimeType;
60
- if (!mimeType) return null;
61
-
62
- const stat = await Bun.file(resolvedPath).stat();
63
- if (stat.size > maxBytes) {
64
- throw new ImageInputTooLargeError(stat.size, maxBytes);
65
- }
66
-
67
- const inputBuffer = await fs.readFile(resolvedPath);
68
- if (inputBuffer.byteLength > maxBytes) {
69
- throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
74
+ options.signal?.throwIfAborted();
75
+ if (options.inputBuffer.byteLength > maxBytes) {
76
+ throw new ImageInputTooLargeError(options.inputBuffer.byteLength, maxBytes);
70
77
  }
71
78
 
72
- let outputData = Buffer.from(inputBuffer).toBase64();
73
- let outputMimeType = mimeType;
74
- let outputBytes = inputBuffer.byteLength;
79
+ let outputBuffer: Uint8Array = options.inputBuffer;
80
+ let outputMimeType = options.mimeType;
75
81
  let dimensionNote: string | undefined;
76
82
 
77
83
  if (options.autoResize) {
84
+ options.signal?.throwIfAborted();
78
85
  try {
79
- const resized = await resizeImage({ type: "image", data: outputData, mimeType });
80
- outputData = resized.data;
86
+ const resized = await resizeImageBuffer(
87
+ options.inputBuffer,
88
+ options.mimeType,
89
+ options.maxOutputBytes === undefined ? undefined : { maxBytes: options.maxOutputBytes },
90
+ );
91
+ options.signal?.throwIfAborted();
92
+ outputBuffer = resized.buffer;
81
93
  outputMimeType = resized.mimeType;
82
- outputBytes = resized.buffer.byteLength;
83
94
  dimensionNote = formatDimensionNote(resized);
84
95
  } catch {
85
- // keep original image when resize fails
96
+ options.signal?.throwIfAborted();
97
+ // Keep the original image when optional resize fails.
86
98
  }
87
99
  }
88
100
 
101
+ options.signal?.throwIfAborted();
89
102
  let textNote = `Read image file [${outputMimeType}]`;
90
- if (dimensionNote) {
91
- textNote += `\n${dimensionNote}`;
92
- }
93
-
103
+ if (dimensionNote) textNote += `\n${dimensionNote}`;
94
104
  return {
95
- resolvedPath,
105
+ resolvedPath: options.resolvedPath,
96
106
  mimeType: outputMimeType,
97
- data: outputData,
107
+ buffer: outputBuffer,
98
108
  textNote,
99
109
  dimensionNote,
100
- bytes: outputBytes,
110
+ bytes: outputBuffer.byteLength,
111
+ };
112
+ }
113
+
114
+ export function materializeImageInput(transformed: TransformedImageInput): LoadedImageInput {
115
+ return {
116
+ resolvedPath: transformed.resolvedPath,
117
+ mimeType: transformed.mimeType,
118
+ data: Buffer.from(transformed.buffer).toBase64(),
119
+ textNote: transformed.textNote,
120
+ dimensionNote: transformed.dimensionNote,
121
+ bytes: transformed.bytes,
101
122
  };
102
123
  }
124
+
125
+ /** Build a model image payload from bytes already read through a trusted descriptor. */
126
+ export async function loadImageInputBytes(options: LoadImageInputBytesOptions): Promise<LoadedImageInput> {
127
+ return materializeImageInput(await transformImageInputBytes(options));
128
+ }
129
+
130
+ export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
131
+ const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
132
+ const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
133
+ options.signal?.throwIfAborted();
134
+ const metadata = options.detectedMimeType
135
+ ? { mimeType: options.detectedMimeType }
136
+ : await readImageMetadata(resolvedPath);
137
+ const mimeType = metadata?.mimeType;
138
+ if (!mimeType) return null;
139
+
140
+ options.signal?.throwIfAborted();
141
+ const stat = await Bun.file(resolvedPath).stat();
142
+ if (stat.size > maxBytes) throw new ImageInputTooLargeError(stat.size, maxBytes);
143
+
144
+ const inputBuffer = await fs.readFile(resolvedPath, { signal: options.signal });
145
+ return loadImageInputBytes({
146
+ inputBuffer,
147
+ resolvedPath,
148
+ mimeType,
149
+ autoResize: options.autoResize,
150
+ maxBytes,
151
+ signal: options.signal,
152
+ });
153
+ }
@@ -21,14 +21,14 @@ export interface ResizedImage {
21
21
 
22
22
  // 500KB target — aggressive compression; Anthropic's 5MB per-image cap is rarely the
23
23
  // binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
24
- const DEFAULT_MAX_BYTES = 500 * 1024;
24
+ export const DEFAULT_IMAGE_RESIZE_MAX_BYTES = 500 * 1024;
25
25
 
26
26
  const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
27
27
  // Anthropic's "internal recommended size" — Anthropic model internally caps images at
28
28
  // 1568px on the longest edge before vision processing.
29
29
  maxWidth: 1568,
30
30
  maxHeight: 1568,
31
- maxBytes: DEFAULT_MAX_BYTES,
31
+ maxBytes: DEFAULT_IMAGE_RESIZE_MAX_BYTES,
32
32
  jpegQuality: 80,
33
33
  };
34
34
 
@@ -72,14 +72,17 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
72
72
  * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
73
73
  * off the JS thread when the terminal (`.bytes()`) is awaited.
74
74
  */
75
- export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
75
+ export async function resizeImageBuffer(
76
+ inputBuffer: Buffer,
77
+ inputMimeType: string,
78
+ options?: ImageResizeOptions,
79
+ ): Promise<ResizedImage> {
76
80
  const excludeWebP = options?.excludeWebP ?? isWebPExcluded();
77
81
  const opts = { ...DEFAULT_OPTIONS, ...options, excludeWebP };
78
- const inputBuffer = Buffer.from(img.data, "base64");
79
82
 
80
83
  try {
81
84
  const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
82
- const sourceMime = img.mimeType ?? `image/${format}`;
85
+ const sourceMime = inputMimeType ?? `image/${format}`;
83
86
 
84
87
  // Fast path: already within dimensions AND well under budget.
85
88
  // Threshold is 1/4 of budget — if already that compact, don't re-encode.
@@ -102,7 +105,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
102
105
  height: originalHeight,
103
106
  wasResized: false,
104
107
  get data() {
105
- return img.data;
108
+ return inputBuffer.toBase64();
106
109
  },
107
110
  };
108
111
  }
@@ -272,24 +275,28 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
272
275
  // When the caller demanded WebP exclusion AND the original is WebP,
273
276
  // returning the original buffer would silently violate that contract,
274
277
  // so surface an explicit error instead.
275
- if (excludeWebP && (img.mimeType === "image/webp" || !img.mimeType)) {
278
+ if (excludeWebP && (inputMimeType === "image/webp" || !inputMimeType)) {
276
279
  throw new Error("resizeImage: failed to decode image and cannot honor excludeWebP for a WebP source");
277
280
  }
278
281
  return {
279
282
  buffer: inputBuffer,
280
- mimeType: img.mimeType,
283
+ mimeType: inputMimeType,
281
284
  originalWidth: 0,
282
285
  originalHeight: 0,
283
286
  width: 0,
284
287
  height: 0,
285
288
  wasResized: false,
286
289
  get data() {
287
- return img.data;
290
+ return inputBuffer.toBase64();
288
291
  },
289
292
  };
290
293
  }
291
294
  }
292
295
 
296
+ export function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
297
+ return resizeImageBuffer(Buffer.from(img.data, "base64"), img.mimeType, options);
298
+ }
299
+
293
300
  /**
294
301
  * Format a dimension note for resized images.
295
302
  * This helps the model understand the coordinate mapping.