@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.
- package/CHANGELOG.md +6 -0
- package/dist/types/config/model-profiles.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/models-config-schema.d.ts +0 -5
- package/dist/types/config/settings-schema.d.ts +78 -9
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
- package/dist/types/tools/computer-gc.d.ts +23 -0
- package/dist/types/tools/cron.d.ts +36 -61
- package/dist/types/tools/index.d.ts +0 -1
- package/dist/types/tools/resource-gc.d.ts +54 -0
- package/dist/types/web/search/index.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +11 -4
- package/package.json +7 -7
- package/src/cli/args.ts +0 -1
- package/src/cli/fast-help.ts +0 -1
- package/src/cli/web-search-cli.ts +5 -0
- package/src/config/model-profiles.ts +3 -4
- package/src/config/model-registry.ts +3 -6
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +80 -10
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -12
- package/src/gjc-runtime/state-renderer.ts +13 -0
- package/src/gjc-runtime/ultragoal-guard.ts +167 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
- package/src/goals/tools/goal-tool.ts +14 -1
- package/src/internal-urls/docs-index.generated.ts +3 -4
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/prompts/system/system-prompt.md +2 -2
- package/src/prompts/tools/cron.md +5 -3
- package/src/prompts/tools/read.md +1 -1
- package/src/sdk.ts +5 -0
- package/src/session/agent-session.ts +44 -0
- package/src/tools/browser/tab-supervisor.ts +86 -2
- package/src/tools/computer-gc.ts +66 -0
- package/src/tools/computer.ts +2 -0
- package/src/tools/cron.ts +75 -112
- package/src/tools/index.ts +2 -8
- package/src/tools/read.ts +25 -55
- package/src/tools/renderers.ts +0 -2
- package/src/tools/resource-gc.ts +291 -0
- package/src/tools/ultragoal-ask-guard.ts +7 -1
- package/src/web/search/index.ts +1 -0
- package/src/web/search/providers/utils.ts +22 -5
- package/vendor/insane-search/MANIFEST.json +3 -1
- package/vendor/insane-search/engine/__init__.py +14 -0
- package/vendor/insane-search/engine/content_safety.py +151 -0
- package/vendor/insane-search/engine/fetch_chain.py +32 -0
- package/vendor/insane-search/engine/tests/test_u8.py +216 -0
- package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
- package/dist/types/tools/inspect-image.d.ts +0 -31
- package/src/prompts/tools/inspect-image-system.md +0 -20
- package/src/prompts/tools/inspect-image.md +0 -32
- package/src/tools/inspect-image-renderer.ts +0 -103
- 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 "
|
|
197
|
+
{{#has tools "read"}}
|
|
198
198
|
<images>
|
|
199
|
-
For image understanding,
|
|
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
|
-
|
|
3
|
+
Use a single `op` field to select the operation:
|
|
4
4
|
|
|
5
|
-
`
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tools/computer.ts
CHANGED
|
@@ -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
|
|