@gajae-code/coding-agent 0.7.6 → 0.7.7

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 (59) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/types/config/model-profiles.d.ts +2 -2
  3. package/dist/types/config/model-registry.d.ts +3 -3
  4. package/dist/types/config/models-config-schema.d.ts +0 -5
  5. package/dist/types/config/settings-schema.d.ts +78 -9
  6. package/dist/types/export/html/template.generated.d.ts +1 -1
  7. package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
  8. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
  9. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
  10. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  11. package/dist/types/tools/computer-gc.d.ts +23 -0
  12. package/dist/types/tools/cron.d.ts +36 -61
  13. package/dist/types/tools/index.d.ts +0 -1
  14. package/dist/types/tools/resource-gc.d.ts +54 -0
  15. package/dist/types/web/search/index.d.ts +1 -0
  16. package/dist/types/web/search/providers/utils.d.ts +11 -4
  17. package/package.json +7 -7
  18. package/src/cli/args.ts +0 -1
  19. package/src/cli/fast-help.ts +0 -1
  20. package/src/cli/web-search-cli.ts +5 -0
  21. package/src/config/model-profiles.ts +3 -4
  22. package/src/config/model-registry.ts +3 -6
  23. package/src/config/models-config-schema.ts +1 -1
  24. package/src/config/settings-schema.ts +80 -10
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.js +0 -12
  27. package/src/gjc-runtime/state-renderer.ts +13 -0
  28. package/src/gjc-runtime/ultragoal-guard.ts +167 -0
  29. package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
  30. package/src/goals/tools/goal-tool.ts +14 -1
  31. package/src/internal-urls/docs-index.generated.ts +3 -4
  32. package/src/modes/controllers/selector-controller.ts +6 -0
  33. package/src/prompts/system/system-prompt.md +2 -2
  34. package/src/prompts/tools/cron.md +5 -3
  35. package/src/prompts/tools/read.md +1 -1
  36. package/src/sdk.ts +5 -0
  37. package/src/session/agent-session.ts +44 -0
  38. package/src/tools/browser/tab-supervisor.ts +86 -2
  39. package/src/tools/computer-gc.ts +66 -0
  40. package/src/tools/computer.ts +2 -0
  41. package/src/tools/cron.ts +75 -112
  42. package/src/tools/index.ts +2 -8
  43. package/src/tools/read.ts +25 -55
  44. package/src/tools/renderers.ts +0 -2
  45. package/src/tools/resource-gc.ts +291 -0
  46. package/src/tools/ultragoal-ask-guard.ts +7 -1
  47. package/src/web/search/index.ts +1 -0
  48. package/src/web/search/providers/utils.ts +22 -5
  49. package/vendor/insane-search/MANIFEST.json +3 -1
  50. package/vendor/insane-search/engine/__init__.py +14 -0
  51. package/vendor/insane-search/engine/content_safety.py +151 -0
  52. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  53. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  54. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  55. package/dist/types/tools/inspect-image.d.ts +0 -31
  56. package/src/prompts/tools/inspect-image-system.md +0 -20
  57. package/src/prompts/tools/inspect-image.md +0 -32
  58. package/src/tools/inspect-image-renderer.ts +0 -103
  59. package/src/tools/inspect-image.ts +0 -172
@@ -51,6 +51,7 @@ import {
51
51
  setPreferredImageProvider,
52
52
  setPreferredSearchProvider,
53
53
  setSearchFallbackProviders,
54
+ setSearchHardTimeoutMs,
54
55
  } from "../../tools";
55
56
  import { setSessionTerminalTitle } from "../../utils/title-generator";
56
57
  import { AgentDashboard } from "../components/agent-dashboard";
@@ -641,6 +642,11 @@ export class SelectorController {
641
642
  );
642
643
  }
643
644
  break;
645
+ case "web_search.timeout":
646
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
647
+ setSearchHardTimeoutMs(value * 1000);
648
+ }
649
+ break;
644
650
  case "providers.image":
645
651
  if (
646
652
  value === "auto" ||
@@ -194,9 +194,9 @@ Delegate by default for multi-file changes, refactors, new features, tests, and
194
194
  </detached-subagents>
195
195
  {{/has}}
196
196
 
197
- {{#has tools "inspect_image"}}
197
+ {{#has tools "read"}}
198
198
  <images>
199
- For image understanding, use `{{toolRefs.inspect_image}}` with a specific question instead of reading raw image metadata only.
199
+ For image understanding, call `{{toolRefs.read}}` on the image path; the image is returned inline for direct visual inspection.
200
200
  </images>
201
201
  {{/has}}
202
202
 
@@ -1,12 +1,14 @@
1
1
  Schedule a prompt to fire on a recurring cron schedule, or one-shot at the next match. Cron tasks let you re-run a prompt automatically on an interval — poll a deployment, babysit a PR, check back on a long-running build, or remind yourself to do something later in the session.
2
2
 
3
- `CronCreate` accepts a standard 5-field cron expression in your local timezone, the prompt to run, and whether the job recurs or fires once. It returns an 8-character job id you can pass to `CronDelete`. Each session can hold up to 50 scheduled tasks. Recurring tasks auto-expire 7 days after creation; one-shot tasks self-delete after firing.
3
+ Use a single `op` field to select the operation:
4
4
 
5
- `CronList` enumerates every scheduled task in the session. `CronDelete` cancels a task by id.
5
+ - `op: "create"` accepts a standard 5-field `cron_expression` in your local timezone, the `prompt` to run, and `recurring` (whether the job recurs or fires once). It returns an 8-character job id you can pass to `op: "delete"`. Each session can hold up to 50 scheduled tasks. Recurring tasks auto-expire 7 days after creation; one-shot tasks self-delete after firing.
6
+ - `op: "list"` enumerates every scheduled task in the session.
7
+ - `op: "delete"` cancels a task by `id`.
6
8
 
7
9
  ## Cron expressions
8
10
 
9
- `CronCreate` accepts 5-field cron: `minute hour day-of-month month day-of-week`. All fields support `*`, single values (`5`), steps (`*/15`), ranges (`1-5`), and comma lists (`1,15,30`). Day-of-week uses `0`/`7` for Sunday through `6` for Saturday. Extended syntax like `L`, `W`, `?`, or month/weekday names is not supported.
11
+ `op: "create"` accepts 5-field cron: `minute hour day-of-month month day-of-week`. All fields support `*`, single values (`5`), steps (`*/15`), ranges (`1-5`), and comma lists (`1,15,30`). Day-of-week uses `0`/`7` for Sunday through `6` for Saturday. Extended syntax like `L`, `W`, `?`, or month/weekday names is not supported.
10
12
 
11
13
  | Example | Meaning |
12
14
  | :------------ | :--------------------------- |
@@ -46,7 +46,7 @@ Extracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipy
46
46
 
47
47
  # Images
48
48
 
49
- Reading an image path returns metadata (mime, bytes, dimensions, channels, alpha). For actual visual analysis, call `inspect_image` with the path and a question describing what to inspect.
49
+ Reading an image path returns the image itself for visual inspection by a vision-capable model.
50
50
 
51
51
  # Archives
52
52
 
package/src/sdk.ts CHANGED
@@ -140,6 +140,7 @@ import {
140
140
  setPreferredImageProvider,
141
141
  setPreferredSearchProvider,
142
142
  setSearchFallbackProviders,
143
+ setSearchHardTimeoutMs,
143
144
  type Tool,
144
145
  type ToolSession,
145
146
  WebSearchTool,
@@ -941,6 +942,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
941
942
  webSearchFallback.filter(value => typeof value === "string" && isConfigurableSearchProviderId(value)),
942
943
  );
943
944
  }
945
+ const webSearchTimeout = settings.get("web_search.timeout");
946
+ if (typeof webSearchTimeout === "number" && Number.isFinite(webSearchTimeout) && webSearchTimeout > 0) {
947
+ setSearchHardTimeoutMs(webSearchTimeout * 1000);
948
+ }
944
949
 
945
950
  const imageProvider = settings.get("providers.image");
946
951
  if (
@@ -250,6 +250,7 @@ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
250
250
  import type { CheckpointState } from "../tools/checkpoint";
251
251
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
252
252
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
253
+ import { registerResourceGcSession } from "../tools/resource-gc";
253
254
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
254
255
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
255
256
  import { clampTimeout } from "../tools/tool-timeouts";
@@ -964,6 +965,8 @@ export class AgentSession {
964
965
  // Python execution state
965
966
  #evalAbortControllers = new Set<AbortController>();
966
967
  #evalKernelOwnerId: string;
968
+ /** Idempotent unregister handle for this session's resource-GC registration. */
969
+ #unregisterResourceGc?: () => void;
967
970
  /**
968
971
  * AsyncJobManager owned by this session (top-level only). Subagents leave
969
972
  * this undefined and **MUST NOT** dispose the global instance on teardown.
@@ -1180,6 +1183,15 @@ export class AgentSession {
1180
1183
  this.sessionManager = config.sessionManager;
1181
1184
  this.settings = config.settings;
1182
1185
  this.taskDepth = config.taskDepth ?? 0;
1186
+ // Register this session with the process-wide resource GC (idle/RSS browser-tab eviction
1187
+ // + stale screenshot cleanup). Session-keyed so concurrent sessions share one timer safely.
1188
+ const resourceGcSessionId = this.sessionManager.getSessionId();
1189
+ if (resourceGcSessionId) {
1190
+ this.#unregisterResourceGc = registerResourceGcSession({
1191
+ sessionId: resourceGcSessionId,
1192
+ settings: this.settings,
1193
+ });
1194
+ }
1183
1195
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
1184
1196
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1185
1197
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
@@ -3285,6 +3297,8 @@ export class AgentSession {
3285
3297
  // browsers disconnect, headless close gracefully). Scoped by the session id the
3286
3298
  // browser tool tagged tabs with, so other live sessions' tabs are untouched.
3287
3299
  // No-op when this session opened no tabs. Failure is logged, not thrown.
3300
+ this.#unregisterResourceGc?.();
3301
+ this.#unregisterResourceGc = undefined;
3288
3302
  await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
3289
3303
  logger.warn("session dispose: releaseTabsForOwner failed", { error }),
3290
3304
  );
@@ -3324,6 +3338,8 @@ export class AgentSession {
3324
3338
  async disposeChildSubprocesses(timeoutMs = SIGNAL_TEARDOWN_TIMEOUT_MS): Promise<void> {
3325
3339
  const sessionId = this.sessionManager.getSessionId();
3326
3340
  const kernelOwnerId = this.#evalKernelOwnerId;
3341
+ this.#unregisterResourceGc?.();
3342
+ this.#unregisterResourceGc = undefined;
3327
3343
  const work = Promise.allSettled([
3328
3344
  // kill:true so a forced exit also reaps spawned-app Chrome we own (headless
3329
3345
  // always closes; connected/attached browsers only disconnect — never killed).
@@ -5231,6 +5247,19 @@ export class AgentSession {
5231
5247
  attribution: "user",
5232
5248
  timestamp: Date.now(),
5233
5249
  });
5250
+ // A live agent loop polls the steering queue at every tool/turn boundary
5251
+ // and consumes this message on its own. But when a steer is queued while no
5252
+ // loop is actively running — e.g. the session still reports busy only
5253
+ // because a finished prompt is unwinding (deferred agent_end / post-prompt
5254
+ // work) — nothing delivers it until the next explicit prompt or a
5255
+ // user-interrupt abort, so it stalls until the user presses Esc. Schedule a
5256
+ // continue so the steer is delivered promptly. A live loop (or an
5257
+ // already-drained queue) makes the scheduled continue a no-op.
5258
+ if (this.#canAutoContinueForSteer()) {
5259
+ this.#scheduleAgentContinue({
5260
+ shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
5261
+ });
5262
+ }
5234
5263
  }
5235
5264
 
5236
5265
  /**
@@ -5275,6 +5304,21 @@ export class AgentSession {
5275
5304
  return last?.role === "assistant";
5276
5305
  }
5277
5306
 
5307
+ /**
5308
+ * Gate for idle / winding-down steer auto-continue. Unlike the follow-up gate
5309
+ * this checks `agent.state.isStreaming` (a live agent loop) rather than the
5310
+ * public `isStreaming` (which stays true while a finished prompt unwinds), so a
5311
+ * steer queued during the unwind window is still delivered. A live loop returns
5312
+ * false here because it polls the steering queue itself.
5313
+ */
5314
+ #canAutoContinueForSteer(): boolean {
5315
+ if (this.agent.state.isStreaming) return false;
5316
+ if (this.isRetrying) return false;
5317
+ const messages = this.agent.state.messages;
5318
+ const last = messages[messages.length - 1];
5319
+ return last?.role === "assistant";
5320
+ }
5321
+
5278
5322
  queueDeferredMessage(message: CustomMessage): void {
5279
5323
  this.#queueHiddenNextTurnMessage(message, true);
5280
5324
  }
@@ -57,6 +57,10 @@ export interface TabSession {
57
57
  kindTag: BrowserKindTag;
58
58
  /** Session that acquired this tab; used for session-scoped teardown (F13). */
59
59
  ownerId?: string;
60
+ /** Unix-ms timestamp of the last acquire/run activity; drives GC idle + LRU ordering. */
61
+ lastUsedAt: number;
62
+ /** Set synchronously by the shared begin-release guard so concurrent release is a no-op. */
63
+ releasing?: boolean;
60
64
  }
61
65
 
62
66
  export interface AcquireTabOptions {
@@ -94,6 +98,76 @@ export function getTab(name: string): TabSession | undefined {
94
98
  return tabs.get(name);
95
99
  }
96
100
 
101
+ /**
102
+ * Shared synchronous begin-release guard. Returns true for the first caller (which then
103
+ * owns teardown) and false for any concurrent/repeat caller, so `releaseBrowser` and the
104
+ * `BrowserHandle.refCount` decrement happen exactly once even when the idle sweep, the RSS
105
+ * sweep, a manual close, and `forceKillTab` race on the same tab.
106
+ */
107
+ function beginRelease(tab: TabSession): boolean {
108
+ if (tab.releasing) return false;
109
+ tab.releasing = true;
110
+ return true;
111
+ }
112
+
113
+ /** Read-only, GC-facing projection of a live tab. Never exposes the mutable `tabs` map. */
114
+ export interface TabGcSnapshot {
115
+ name: string;
116
+ ownerId?: string;
117
+ state: TabSession["state"];
118
+ pendingCount: number;
119
+ kindTag: BrowserKindTag;
120
+ lastUsedAt: number;
121
+ browserRefCount: number;
122
+ }
123
+
124
+ export interface BrowserGcEligibilityPolicy {
125
+ now: () => number;
126
+ idleMs: number;
127
+ }
128
+
129
+ /** Snapshot every live tab for GC ordering/diagnostics. */
130
+ export function listTabsForGc(): TabGcSnapshot[] {
131
+ return [...tabs.values()].map(tab => ({
132
+ name: tab.name,
133
+ ownerId: tab.ownerId,
134
+ state: tab.state,
135
+ pendingCount: tab.pending.size,
136
+ kindTag: tab.kindTag,
137
+ lastUsedAt: tab.lastUsedAt,
138
+ browserRefCount: tab.browser.refCount,
139
+ }));
140
+ }
141
+
142
+ /**
143
+ * Evict a tab only if it is still GC-eligible against the LIVE supervisor state. The full
144
+ * predicate is checked synchronously and `releaseTab` is invoked with no intervening await,
145
+ * so a tab that became busy after a GC snapshot cannot be closed (its run rejected). Returns
146
+ * true only when the tab was actually released.
147
+ */
148
+ export async function releaseTabIfGcEligible(name: string, policy: BrowserGcEligibilityPolicy): Promise<boolean> {
149
+ const tab = tabs.get(name);
150
+ if (!tab) return false;
151
+ if (tab.releasing) return false;
152
+ if (tab.state !== "alive") return false;
153
+ if (tab.pending.size !== 0) return false;
154
+ if (tab.kindTag !== "headless" && tab.kindTag !== "spawned") return false;
155
+ if (policy.now() - tab.lastUsedAt <= policy.idleMs) return false;
156
+ // All predicates passed synchronously; releaseTab applies the shared begin-release guard
157
+ // on the same microtask, so no other code runs between the check and the state transition.
158
+ return await releaseTab(name, { kill: false });
159
+ }
160
+
161
+ /** Test-only: install a fabricated tab session. */
162
+ export function setTabForTest(tab: TabSession): void {
163
+ tabs.set(tab.name, tab);
164
+ }
165
+
166
+ /** Test-only: clear the tab registry between cases. */
167
+ export function clearTabsForTest(): void {
168
+ tabs.clear();
169
+ }
170
+
97
171
  export async function acquireTab(
98
172
  name: string,
99
173
  browser: BrowserHandle,
@@ -105,6 +179,7 @@ export async function acquireTab(
105
179
  if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
106
180
  await releaseTab(name, { kill: false });
107
181
  } else {
182
+ existing.lastUsedAt = Date.now();
108
183
  if (opts.url) {
109
184
  await runInTabWithSnapshot(
110
185
  name,
@@ -166,6 +241,7 @@ export async function acquireTab(
166
241
  dialogPolicy: opts.dialogs,
167
242
  kindTag: browser.kind.kind,
168
243
  ownerId: opts.ownerId,
244
+ lastUsedAt: Date.now(),
169
245
  };
170
246
  worker.onMessage(msg => handleTabMessage(tab, msg));
171
247
  tabs.set(name, tab);
@@ -188,6 +264,7 @@ async function runInTabWithSnapshot(
188
264
  const tab = tabs.get(name);
189
265
  if (!tab || tab.state === "dead") throw new ToolError(`Tab ${JSON.stringify(name)} is not alive. Reopen it.`);
190
266
  if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
267
+ tab.lastUsedAt = Date.now();
191
268
  const id = Snowflake.next();
192
269
  const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
193
270
  const pending: PendingRun = {
@@ -224,6 +301,10 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
224
301
  logger.debug("releaseTab: unknown tab", { name });
225
302
  return false;
226
303
  }
304
+ if (!beginRelease(tab)) {
305
+ logger.debug("releaseTab: already releasing", { name });
306
+ return false;
307
+ }
227
308
  const wasAlive = tab.state === "alive";
228
309
  tab.state = "dead";
229
310
  const closeError = new ToolError(`Tab ${JSON.stringify(name)} was closed`);
@@ -246,7 +327,9 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
246
327
  await tab.worker.terminate().catch(() => undefined);
247
328
  if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
248
329
  await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
249
- tabs.delete(name);
330
+ // Only delete if the map still holds THIS tab: a same-name reacquire during our async
331
+ // teardown may have installed a fresh tab that we must not evict.
332
+ if (tabs.get(name) === tab) tabs.delete(name);
250
333
  return true;
251
334
  }
252
335
 
@@ -392,6 +475,7 @@ function toErrorPayload(error: unknown): RunErrorPayload {
392
475
  async function forceKillTab(name: string, reason: string): Promise<void> {
393
476
  const tab = tabs.get(name);
394
477
  if (!tab) return;
478
+ if (!beginRelease(tab)) return;
395
479
  tab.state = "dead";
396
480
  const error = new ToolError(reason);
397
481
  for (const pending of tab.pending.values()) pending.reject(error);
@@ -399,7 +483,7 @@ async function forceKillTab(name: string, reason: string): Promise<void> {
399
483
  await tab.worker.terminate().catch(() => undefined);
400
484
  if (tab.kindTag === "headless") await closeOrphanTarget(tab);
401
485
  await releaseBrowser(tab.browser, { kill: false });
402
- tabs.delete(name);
486
+ if (tabs.get(name) === tab) tabs.delete(name);
403
487
  }
404
488
 
405
489
  async function closeOrphanTarget(tab: TabSession): Promise<void> {
@@ -0,0 +1,66 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { logger } from "@gajae-code/utils";
6
+
7
+ /** Prefix of every computer-use screenshot fallback directory created under the OS temp dir. */
8
+ export const SCREENSHOT_FALLBACK_DIR_PREFIX = "gjc-computer-screenshots-";
9
+
10
+ let screenshotFallbackDirsCreated = false;
11
+
12
+ /** Lazy-arm marker: called when the first screenshot fallback dir is created this process. */
13
+ export function markScreenshotFallbackDirCreatedForGc(): void {
14
+ screenshotFallbackDirsCreated = true;
15
+ }
16
+
17
+ /** Whether any screenshot fallback dir has been created this process (GC lazy-arm gate). */
18
+ export function hasCreatedScreenshotFallbackDir(): boolean {
19
+ return screenshotFallbackDirsCreated;
20
+ }
21
+
22
+ /** Test-only: reset the lazy-arm marker between cases. */
23
+ export function resetScreenshotFallbackGcForTest(): void {
24
+ screenshotFallbackDirsCreated = false;
25
+ }
26
+
27
+ export interface ScreenshotGcOptions {
28
+ now: () => number;
29
+ staleMs: number;
30
+ /** Base dir to scan; defaults to os.tmpdir(). Injectable for tests. */
31
+ tmpDir?: string;
32
+ }
33
+
34
+ /**
35
+ * Disk-only GC for stale computer-use screenshot fallback directories. Scans the temp dir for
36
+ * `gjc-computer-screenshots-*` directories and removes those whose mtime is older than `staleMs`.
37
+ * Never throws on a per-directory failure; the whole sweep is best-effort.
38
+ */
39
+ export async function cleanupStaleScreenshotFallbackDirs(
40
+ options: ScreenshotGcOptions,
41
+ ): Promise<{ scanned: number; removed: number }> {
42
+ const base = options.tmpDir ?? os.tmpdir();
43
+ const entries: Dirent[] = await fs.readdir(base, { withFileTypes: true }).catch(err => {
44
+ logger.debug("screenshot GC: failed to read temp dir", { base, error: (err as Error).message });
45
+ return [] as Dirent[];
46
+ });
47
+
48
+ const now = options.now();
49
+ let scanned = 0;
50
+ let removed = 0;
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ if (!entry.name.startsWith(SCREENSHOT_FALLBACK_DIR_PREFIX)) continue;
54
+ scanned++;
55
+ const dir = path.join(base, entry.name);
56
+ try {
57
+ const stat = await fs.stat(dir);
58
+ if (now - stat.mtimeMs <= options.staleMs) continue;
59
+ await fs.rm(dir, { recursive: true, force: true });
60
+ removed++;
61
+ } catch (err) {
62
+ logger.debug("screenshot GC: failed to remove stale dir", { dir, error: (err as Error).message });
63
+ }
64
+ }
65
+ return { scanned, removed };
66
+ }
@@ -7,6 +7,7 @@ import { prompt } from "@gajae-code/utils";
7
7
  import * as z from "zod/v4";
8
8
  import computerDescription from "../prompts/tools/computer.md" with { type: "text" };
9
9
  import { resizeImage } from "../utils/image-resize";
10
+ import { markScreenshotFallbackDirCreatedForGc } from "./computer-gc";
10
11
  import type { ToolSession } from "./index";
11
12
  import type { OutputMeta } from "./output-meta";
12
13
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
@@ -547,6 +548,7 @@ function getScreenshotFallbackDir(session: ToolSession): Promise<string> {
547
548
  async function createScreenshotFallbackDir(): Promise<string> {
548
549
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-computer-screenshots-"));
549
550
  await fs.chmod(dir, 0o700);
551
+ markScreenshotFallbackDirCreatedForGc();
550
552
  return dir;
551
553
  }
552
554