@gajae-code/coding-agent 0.7.5 → 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 (64) hide show
  1. package/CHANGELOG.md +11 -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/notifications/lifecycle-commands.d.ts +1 -13
  11. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  12. package/dist/types/tools/computer-gc.d.ts +23 -0
  13. package/dist/types/tools/cron.d.ts +36 -61
  14. package/dist/types/tools/index.d.ts +0 -1
  15. package/dist/types/tools/resource-gc.d.ts +54 -0
  16. package/dist/types/web/search/index.d.ts +1 -0
  17. package/dist/types/web/search/providers/utils.d.ts +11 -4
  18. package/package.json +7 -7
  19. package/src/cli/args.ts +0 -1
  20. package/src/cli/fast-help.ts +29 -2
  21. package/src/cli/web-search-cli.ts +5 -0
  22. package/src/config/model-profiles.ts +3 -4
  23. package/src/config/model-registry.ts +3 -6
  24. package/src/config/models-config-schema.ts +1 -1
  25. package/src/config/settings-schema.ts +80 -10
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.js +0 -12
  28. package/src/gjc-runtime/launch-tmux.ts +14 -8
  29. package/src/gjc-runtime/state-renderer.ts +13 -0
  30. package/src/gjc-runtime/ultragoal-guard.ts +167 -0
  31. package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
  32. package/src/goals/tools/goal-tool.ts +14 -1
  33. package/src/internal-urls/docs-index.generated.ts +4 -5
  34. package/src/modes/controllers/selector-controller.ts +6 -0
  35. package/src/notifications/lifecycle-commands.ts +19 -9
  36. package/src/notifications/lifecycle-control-runtime.ts +7 -2
  37. package/src/prompts/system/system-prompt.md +2 -2
  38. package/src/prompts/tools/cron.md +5 -3
  39. package/src/prompts/tools/read.md +1 -1
  40. package/src/sdk.ts +5 -0
  41. package/src/session/agent-session.ts +44 -0
  42. package/src/session/session-manager.ts +39 -16
  43. package/src/tools/browser/tab-supervisor.ts +86 -2
  44. package/src/tools/computer-gc.ts +66 -0
  45. package/src/tools/computer.ts +2 -0
  46. package/src/tools/cron.ts +75 -112
  47. package/src/tools/index.ts +2 -8
  48. package/src/tools/read.ts +25 -55
  49. package/src/tools/renderers.ts +0 -2
  50. package/src/tools/resource-gc.ts +291 -0
  51. package/src/tools/ultragoal-ask-guard.ts +7 -1
  52. package/src/web/search/index.ts +1 -0
  53. package/src/web/search/providers/utils.ts +22 -5
  54. package/vendor/insane-search/MANIFEST.json +3 -1
  55. package/vendor/insane-search/engine/__init__.py +14 -0
  56. package/vendor/insane-search/engine/content_safety.py +151 -0
  57. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  58. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  59. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  60. package/dist/types/tools/inspect-image.d.ts +0 -31
  61. package/src/prompts/tools/inspect-image-system.md +0 -20
  62. package/src/prompts/tools/inspect-image.md +0 -32
  63. package/src/tools/inspect-image-renderer.ts +0 -103
  64. 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" ||
@@ -11,6 +11,7 @@
11
11
  * prompt text ever enters a frame, audit, log, or response until daemon-owned
12
12
  * 0600 prompt refs are designed.
13
13
  */
14
+ import * as os from "node:os";
14
15
  import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
15
16
 
16
17
  export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
@@ -94,22 +95,21 @@ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycle
94
95
  const kind = args[0];
95
96
  if (kind === "path") {
96
97
  if (args.length !== 2) return { kind: "usage", message: USAGE };
97
- const p = args[1]!;
98
- if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
98
+ const p = normalizeLifecyclePath(args[1]!);
99
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
99
100
  return { kind: "create", target: { kind: "existing_path", path: p } };
100
101
  }
101
102
  if (kind === "dir") {
102
103
  if (args.length !== 2) return { kind: "usage", message: USAGE };
103
- const p = args[1]!;
104
- if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
104
+ const p = normalizeLifecyclePath(args[1]!);
105
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
105
106
  return { kind: "create", target: { kind: "plain_dir", path: p } };
106
107
  }
107
108
  if (kind === "worktree") {
108
109
  if (args.length !== 3) return { kind: "usage", message: USAGE };
109
- const repo = args[1]!;
110
+ const repo = normalizeLifecyclePath(args[1]!);
110
111
  const branch = args[2]!;
111
- if (!isSafePath(repo))
112
- return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
112
+ if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
113
113
  if (!isSafeBranch(branch)) {
114
114
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
115
115
  }
@@ -137,10 +137,10 @@ export function validateLifecycleTarget(
137
137
  if (verb === "session_create") {
138
138
  const t = target as SessionCreateTarget;
139
139
  if (t.kind === "existing_path" || t.kind === "plain_dir") {
140
- return isSafePath(t.path) ? { ok: true } : bad("invalid path");
140
+ return normalizeLifecyclePath(t.path) ? { ok: true } : bad("invalid path");
141
141
  }
142
142
  if (t.kind === "worktree") {
143
- if (!isSafePath(t.repo)) return bad("invalid repo path");
143
+ if (!normalizeLifecyclePath(t.repo)) return bad("invalid repo path");
144
144
  return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
145
145
  }
146
146
  return bad("unknown create target");
@@ -156,6 +156,16 @@ export function validateLifecycleTarget(
156
156
  // --- Safety primitives (defensive; the full-trust paired chat is accepted, but
157
157
  // we still reject obviously malformed/injection-shaped inputs early). ---
158
158
 
159
+ export function normalizeLifecyclePath(value: string): string | undefined {
160
+ if (!isSafePath(value)) return undefined;
161
+ if (value === "~") return os.homedir() || undefined;
162
+ if (value.startsWith("~/")) {
163
+ const home = os.homedir();
164
+ return home ? `${home}${value.slice(1)}` : undefined;
165
+ }
166
+ if (value.startsWith("~")) return undefined;
167
+ return value;
168
+ }
159
169
  function isSafeIdentifier(value: string): boolean {
160
170
  return /^[A-Za-z0-9._-]{1,128}$/.test(value);
161
171
  }
@@ -22,6 +22,7 @@ import {
22
22
  statusGjcTmuxSession,
23
23
  } from "../gjc-runtime/tmux-sessions";
24
24
  import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
25
+ import { normalizeLifecyclePath } from "./lifecycle-commands";
25
26
  import {
26
27
  type AuditEvent,
27
28
  type CreateEffectResult,
@@ -132,12 +133,16 @@ export function buildCreateArgv(
132
133
  _ids: { intendedSessionId: string; startupPromptRef?: string },
133
134
  ): { cwd: string; args: string[] } {
134
135
  if (frame.target.kind === "worktree") {
136
+ const cwd = normalizeLifecyclePath(frame.target.repo);
137
+ if (!cwd) throw new Error("invalid_lifecycle_repo_path");
135
138
  // Use the `--worktree=<branch>` form so the branch is a single argv token:
136
139
  // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
137
140
  // launcher flag / detached-mode trigger.
138
- return { cwd: frame.target.repo, args: [`--worktree=${frame.target.branch}`] };
141
+ return { cwd, args: [`--worktree=${frame.target.branch}`] };
139
142
  }
140
- return { cwd: frame.target.path, args: [] };
143
+ const cwd = normalizeLifecyclePath(frame.target.path);
144
+ if (!cwd) throw new Error("invalid_lifecycle_path");
145
+ return { cwd, args: [] };
141
146
  }
142
147
 
143
148
  /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + GJC tags. */
@@ -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
  }
@@ -1324,6 +1324,8 @@ const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1324
1324
  "toolName",
1325
1325
  "targetId",
1326
1326
  "firstKeptEntryId",
1327
+ "encrypted_content",
1328
+ "reasoning_encrypted_content",
1327
1329
  ]);
1328
1330
 
1329
1331
  function shouldExternalizeResidentString(key: string | undefined): boolean {
@@ -1515,6 +1517,40 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
1515
1517
  return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
1516
1518
  }
1517
1519
 
1520
+ function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
1521
+ if (entry.type === "compaction") {
1522
+ const cache = new Map<string, string>();
1523
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", cache);
1524
+ const shortSummary = materializeResidentValueSync(entry.shortSummary, stores, "shortSummary", cache);
1525
+ const remote = entry.preserveData?.openaiRemoteCompaction;
1526
+ const remoteRecord = isRecord(remote) ? remote : undefined;
1527
+ const replacementHistory = remoteRecord
1528
+ ? materializeResidentValueSync(remoteRecord.replacementHistory, stores, "replacementHistory", cache)
1529
+ : undefined;
1530
+ const preserveData =
1531
+ remoteRecord && replacementHistory !== undefined && replacementHistory !== remoteRecord.replacementHistory
1532
+ ? {
1533
+ ...entry.preserveData,
1534
+ openaiRemoteCompaction: {
1535
+ ...remoteRecord,
1536
+ replacementHistory,
1537
+ },
1538
+ }
1539
+ : entry.preserveData;
1540
+ return {
1541
+ ...entry,
1542
+ summary: typeof summary === "string" ? summary : entry.summary,
1543
+ shortSummary: typeof shortSummary === "string" ? shortSummary : entry.shortSummary,
1544
+ preserveData,
1545
+ };
1546
+ }
1547
+ if (entry.type === "branch_summary") {
1548
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", new Map<string, string>());
1549
+ return typeof summary === "string" ? { ...entry, summary } : { ...entry };
1550
+ }
1551
+ return cloneSessionEntry(entry);
1552
+ }
1553
+
1518
1554
  const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
1519
1555
  const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
1520
1556
  const COLD_SPILL_MIN_CHARS = 1024;
@@ -1533,19 +1569,6 @@ type ColdSpillResidentPromotion = {
1533
1569
  function isRecord(value: unknown): value is Record<string, unknown> {
1534
1570
  return typeof value === "object" && value !== null && !Array.isArray(value);
1535
1571
  }
1536
-
1537
- function isColdSpillRef(value: unknown): value is ColdSpillRef {
1538
- return (
1539
- isRecord(value) &&
1540
- value.kind === "cold_spill" &&
1541
- typeof value.ref === "string" &&
1542
- (value.encoding === "utf8" || value.encoding === "json") &&
1543
- typeof value.originalChars === "number" &&
1544
- typeof value.sha256 === "string" &&
1545
- typeof value.bytes === "number"
1546
- );
1547
- }
1548
-
1549
1572
  function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
1550
1573
  return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
1551
1574
  }
@@ -4008,8 +4031,7 @@ export class SessionManager {
4008
4031
  const firstKept = this.#byId.get(firstKeptEntryId);
4009
4032
  const compaction = this.#byId.get(compactionEntryId);
4010
4033
  if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
4011
- if (!compaction || compaction.type !== "compaction")
4012
- throw new Error(`Compaction entry ${compactionEntryId} not found`);
4034
+ if (compaction?.type !== "compaction") throw new Error(`Compaction entry ${compactionEntryId} not found`);
4013
4035
  const ids: string[] = [];
4014
4036
  const visited = new Set<string>();
4015
4037
  let current: SessionEntry | undefined = compaction;
@@ -4351,7 +4373,8 @@ export class SessionManager {
4351
4373
  if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
4352
4374
  return cloneSessionEntry(entry);
4353
4375
  }
4354
- if (entry.type !== "message" && entry.type !== "custom_message") return cloneSessionEntry(entry);
4376
+ if (entry.type !== "message" && entry.type !== "custom_message")
4377
+ return materializeProviderVisibleEntrySync(entry, this.#residentBlobStores());
4355
4378
  const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
4356
4379
  const rehydrated = rehydrateColdSpillEntry(
4357
4380
  materialized,
@@ -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