@gajae-code/coding-agent 0.11.3 → 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 (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/worktree-cli.d.ts +8 -25
  3. package/dist/types/commands/gc.d.ts +4 -0
  4. package/dist/types/commands/worktree.d.ts +7 -32
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  6. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  7. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  8. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  9. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  10. package/dist/types/sdk/broker/broker.d.ts +1 -0
  11. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  13. package/dist/types/sdk/broker/internal.d.ts +3 -0
  14. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  15. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  16. package/dist/types/tools/ask.d.ts +39 -12
  17. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  18. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  19. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  20. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  21. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  22. package/dist/types/tools/path-utils.d.ts +9 -14
  23. package/dist/types/tools/render-utils.d.ts +1 -8
  24. package/dist/types/tools/resource-gc.d.ts +14 -0
  25. package/dist/types/utils/image-loading.d.ts +23 -0
  26. package/dist/types/utils/image-resize.d.ts +2 -0
  27. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  28. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  29. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  30. package/dist/types/web/scrapers/types.d.ts +1 -5
  31. package/package.json +24 -8
  32. package/scripts/run-threat-model-check.ts +60 -0
  33. package/src/cli/worktree-cli.ts +9 -287
  34. package/src/cli.ts +10 -9
  35. package/src/commands/gc.ts +11 -1
  36. package/src/commands/sdk.ts +3 -2
  37. package/src/commands/worktree.ts +10 -53
  38. package/src/config/model-profiles.ts +15 -15
  39. package/src/eval/js/shared/runtime.ts +1 -1
  40. package/src/gjc-runtime/gc-render.ts +19 -1
  41. package/src/gjc-runtime/gc-runtime.ts +68 -3
  42. package/src/gjc-runtime/state-argv.ts +292 -0
  43. package/src/gjc-runtime/state-runtime.ts +42 -210
  44. package/src/modes/components/custom-editor.ts +103 -32
  45. package/src/modes/components/custom-provider-wizard.ts +26 -1
  46. package/src/modes/components/session-observer-overlay.ts +10 -23
  47. package/src/modes/components/tool-transcript-format.ts +72 -0
  48. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  49. package/src/modes/controllers/input-controller.ts +98 -37
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +13 -2
  52. package/src/sdk/broker/broker.ts +139 -32
  53. package/src/sdk/broker/discovery.ts +125 -0
  54. package/src/sdk/broker/ensure.ts +103 -19
  55. package/src/sdk/broker/internal.ts +10 -0
  56. package/src/sdk/broker/session-index.ts +228 -15
  57. package/src/sdk/broker/state-version.ts +5 -2
  58. package/src/sdk/bus/telegram-daemon.ts +50 -10
  59. package/src/session/internal/managed-session-scope.ts +15 -1
  60. package/src/tools/ask.ts +216 -41
  61. package/src/tools/bash-allowed-prefixes.ts +79 -29
  62. package/src/tools/browser/launch.ts +3 -1
  63. package/src/tools/browser/readable.ts +1 -1
  64. package/src/tools/browser/screenshot-format.ts +34 -0
  65. package/src/tools/browser/tab-protocol.ts +3 -0
  66. package/src/tools/browser/tab-supervisor.ts +125 -122
  67. package/src/tools/browser/tab-worker-entry.ts +33 -14
  68. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  69. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  70. package/src/tools/browser/tab-worker.ts +7 -5
  71. package/src/tools/path-utils.ts +42 -35
  72. package/src/tools/read.ts +7 -1
  73. package/src/tools/render-utils.ts +1 -27
  74. package/src/tools/resource-gc.ts +123 -34
  75. package/src/utils/image-loading.ts +85 -34
  76. package/src/utils/image-resize.ts +16 -9
  77. package/src/utils/pasted-image-loading.ts +514 -0
  78. package/src/utils/pasted-image-path.ts +223 -98
  79. package/src/web/scrapers/html-to-markdown.ts +68 -0
  80. package/src/web/scrapers/readthedocs.ts +1 -1
  81. package/src/web/scrapers/types.ts +1 -68
  82. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
- import { isEnoent } from "@gajae-code/utils";
5
+ import { isEnoent } from "@gajae-code/utils/fs-error";
6
6
  import { InternalUrlRouter } from "../internal-urls";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
@@ -10,13 +10,9 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
10
10
  const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
11
11
  const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
12
12
  const FILE_RAW_ONLY_RE = /^raw$/i;
13
- // Permissive selector chunk for internal URLs — accepts well-formed selectors
14
- // plus common malformed shapes (e.g. `:-N`) so the read tool peels the entire
15
- // selector chain off before dispatching to a protocol handler.
16
- const INTERNAL_URL_SELECTOR_PART_RE =
17
- /^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i;
18
- // Schemes whose host grammar is identifier-shaped, so any trailing
19
- // `:<selector-chunk>` is unambiguously a read-tool selector.
13
+ // Schemes whose authority grammar is identifier-shaped and may therefore carry
14
+ // a read selector immediately after the authority. Colons in a path, query, or
15
+ // fragment remain part of the URL and are never considered selectors.
20
16
  const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
21
17
  agent: true,
22
18
  artifact: true,
@@ -148,39 +144,50 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
148
144
  /**
149
145
  * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
150
146
  *
151
- * The filesystem-path splitter is intentionally conservative: it refuses to
152
- * peel a trailing `:<chunk>` unless that chunk matches the strict selector
153
- * grammar. That rule is right for filesystem paths (a file named `a:1-50` is
154
- * legal) but wrong for internal URLs, where any trailing `:<chunk>` after the
155
- * scheme is unambiguously a read-tool selector — even if malformed (e.g.
156
- * `artifact://3:raw:-100`).
157
- *
158
- * This function iteratively peels selector-shaped chunks (well-formed plus
159
- * common malformed shapes like `:-N`) so the rest of the read tool can pass a
160
- * clean URL to the protocol handler and surface selector errors via parseSel
161
- * instead of as misleading "host invalid" errors from the handler.
162
- *
163
- * Falls back to the input unchanged when nothing matches.
147
+ * Artifact authorities are generated identifiers, so their complete tail is
148
+ * unambiguously a selector and malformed selectors can be rejected before
149
+ * resolving the artifact. Other resources may legitimately use colons in
150
+ * their identities, so they only lose strict, recognized selector suffixes.
151
+ * Skill authorities additionally use the active registry to distinguish a
152
+ * skill name from its selector without making path-utils depend on skills.
164
153
  */
165
- export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } {
154
+ export function splitInternalUrlSel(
155
+ rawPath: string,
156
+ options: { activeSkillNames?: readonly string[] } = {},
157
+ ): { path: string; sel?: string } {
166
158
  const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
167
159
  if (!schemeMatch) return { path: rawPath };
168
- if (!INTERNAL_SCHEMES_WITH_SELECTORS[schemeMatch[1].toLowerCase()]) return { path: rawPath };
160
+ const scheme = schemeMatch[1].toLowerCase();
161
+ if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
169
162
 
170
163
  const schemeEnd = schemeMatch[0].length;
171
- let path = rawPath;
172
- const chunks: string[] = [];
173
- while (true) {
174
- const colon = path.lastIndexOf(":");
175
- // Stop before crossing into the scheme separator `://`.
176
- if (colon < schemeEnd) break;
177
- const tail = path.slice(colon + 1);
178
- if (!INTERNAL_URL_SELECTOR_PART_RE.test(tail)) break;
179
- chunks.unshift(tail);
180
- path = path.slice(0, colon);
164
+ const authorityTerminator = rawPath.slice(schemeEnd).search(/[/?#]/);
165
+ if (authorityTerminator !== -1) return { path: rawPath };
166
+ const authority = rawPath.slice(schemeEnd);
167
+ const firstColon = authority.indexOf(":");
168
+ if (firstColon === -1) return { path: rawPath };
169
+
170
+ if (scheme === "artifact") {
171
+ return { path: rawPath.slice(0, schemeEnd + firstColon), sel: authority.slice(firstColon + 1) };
172
+ }
173
+
174
+ if (scheme === "skill") {
175
+ const activeSkillNames = options.activeSkillNames ?? [];
176
+ if (activeSkillNames.includes(authority)) return { path: rawPath };
177
+ const skillName = activeSkillNames
178
+ .filter(name => authority.startsWith(`${name}:`))
179
+ .sort((a, b) => b.length - a.length)[0];
180
+ if (skillName) {
181
+ return {
182
+ path: `${rawPath.slice(0, schemeEnd)}${skillName}`,
183
+ sel: authority.slice(skillName.length + 1),
184
+ };
185
+ }
181
186
  }
182
- if (chunks.length === 0) return { path: rawPath };
183
- return { path, sel: chunks.join(":") };
187
+
188
+ const strict = splitPathAndSel(authority);
189
+ if (strict.sel === undefined) return { path: rawPath };
190
+ return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}`, sel: strict.sel };
184
191
  }
185
192
 
186
193
  function assertNotInternalUrl(expanded: string, original: string): void {
package/src/tools/read.ts CHANGED
@@ -11,6 +11,7 @@ import * as z from "zod/v4";
11
11
  import { getFileReadCache } from "../edit/file-read-cache";
12
12
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
+ import { getActiveSkills } from "../extensibility/skills";
14
15
  import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
16
  import { InternalUrlRouter } from "../internal-urls";
16
17
  import { parseInternalUrl } from "../internal-urls/parse";
@@ -1585,8 +1586,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1585
1586
  // off the URL and surfaced via parseSel rather than confusing handlers.
1586
1587
  const internalRouter = InternalUrlRouter.instance();
1587
1588
  if (internalRouter.canHandle(readPath)) {
1588
- const internalTarget = splitInternalUrlSel(readPath);
1589
+ const internalTarget = splitInternalUrlSel(readPath, {
1590
+ activeSkillNames: getActiveSkills().map(skill => skill.name),
1591
+ });
1589
1592
  const parsed = parseSel(internalTarget.sel);
1593
+ if (internalTarget.sel !== undefined && parsed.kind === "none") {
1594
+ throw new ToolError(`Invalid internal URL selector "${internalTarget.sel}".`);
1595
+ }
1590
1596
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
1591
1597
  }
1592
1598
 
@@ -15,10 +15,10 @@ import { pluralize } from "@gajae-code/utils";
15
15
  import { settings } from "../config/settings";
16
16
  import type { Theme } from "../modes/theme/theme";
17
17
  import { Hasher } from "../tui/utils";
18
- import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
19
18
 
20
19
  export { Ellipsis } from "@gajae-code/natives";
21
20
  export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@gajae-code/tui";
21
+ export { formatScreenshot } from "./browser/screenshot-format";
22
22
 
23
23
  // =============================================================================
24
24
  // Standardized Display Constants
@@ -591,32 +591,6 @@ export function formatToolWorkingDirectory(workdir: string | undefined, projectD
591
591
  return replaceTabs(displayWorkdir);
592
592
  }
593
593
 
594
- export function formatScreenshot(opts: {
595
- saveFullRes: boolean;
596
- savedMimeType: string;
597
- savedByteLength: number;
598
- dest: string;
599
- resized: ResizedImage;
600
- }): string[] {
601
- const lines = ["Screenshot captured"];
602
- if (opts.saveFullRes) {
603
- lines.push(
604
- `Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
605
- );
606
- lines.push(
607
- `Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
608
- );
609
- } else {
610
- lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
611
- lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
612
- }
613
- const dimensionNote = formatDimensionNote(opts.resized);
614
- if (dimensionNote) {
615
- lines.push(dimensionNote);
616
- }
617
- return lines;
618
- }
619
-
620
594
  export function wrapBrackets(text: string, theme: Theme): string {
621
595
  return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
622
596
  }
@@ -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
  }
@@ -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.