@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.
- package/CHANGELOG.md +11 -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/notifications/lifecycle-commands.d.ts +1 -13
- 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 +29 -2
- 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/launch-tmux.ts +14 -8
- 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 +4 -5
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/notifications/lifecycle-commands.ts +19 -9
- package/src/notifications/lifecycle-control-runtime.ts +7 -2
- 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/session/session-manager.ts +39 -16
- 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" ||
|
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
|
140
|
+
return normalizeLifecyclePath(t.path) ? { ok: true } : bad("invalid path");
|
|
141
141
|
}
|
|
142
142
|
if (t.kind === "worktree") {
|
|
143
|
-
if (!
|
|
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
|
|
141
|
+
return { cwd, args: [`--worktree=${frame.target.branch}`] };
|
|
139
142
|
}
|
|
140
|
-
|
|
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 "
|
|
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
|
}
|
|
@@ -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 (
|
|
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")
|
|
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
|
-
|
|
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
|
|