@sayknow-cli/coding-agent 0.3.2 → 0.3.5
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/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/skc-runtime/state-renderer.d.ts +5 -0
- package/dist/types/skc-runtime/ultragoal-guard.d.ts +37 -1
- package/dist/types/skc-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/scripts/verify-insane-vendor.ts +5 -0
- package/src/cli/args.ts +0 -1
- package/src/cli/fast-help.ts +0 -1
- package/src/cli/plugin-cli.ts +1 -1
- package/src/cli/web-search-cli.ts +5 -0
- package/src/config/model-profile-activation.ts +7 -1
- 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/goals/tools/goal-tool.ts +14 -1
- package/src/internal-urls/docs-index.generated.ts +3 -4
- package/src/modes/components/model-selector.ts +9 -1
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/prompts/system/system-prompt.md +16 -20
- 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/skc-runtime/state-renderer.ts +13 -0
- package/src/skc-runtime/ultragoal-guard.ts +167 -0
- package/src/skc-runtime/ultragoal-runtime.ts +221 -1
- 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 +5 -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_u1.py +28 -0
- package/vendor/insane-search/engine/tests/test_u8.py +216 -0
- package/vendor/insane-search/engine/transport.py +3 -2
- package/vendor/insane-search/engine/validators.py +14 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { inflateSync } from "node:zlib";
|
|
4
5
|
import { normalizeGoal } from "../goals/state";
|
|
@@ -11,7 +12,13 @@ import { sessionUltragoalDir, skcRoot } from "./session-layout";
|
|
|
11
12
|
import { resolveSkcSessionForRead, resolveSkcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
|
|
12
13
|
import { renderUltragoalStatusMarkdown } from "./state-renderer";
|
|
13
14
|
import { reconcileWorkflowSkillState } from "./state-runtime";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
appendJsonl,
|
|
17
|
+
persistedStateRevision,
|
|
18
|
+
withWorkflowStateLock,
|
|
19
|
+
writeArtifact,
|
|
20
|
+
writeGuardedJsonAtomic,
|
|
21
|
+
} from "./state-writer";
|
|
15
22
|
|
|
16
23
|
export type UltragoalSkcGoalMode = "aggregate" | "per-story";
|
|
17
24
|
export type UltragoalGoalStatus =
|
|
@@ -80,6 +87,44 @@ export interface UltragoalLedgerEvent extends JsonObject {
|
|
|
80
87
|
timestamp?: string;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
export type UltragoalNudgeSurface = "pause" | "drop" | "ask" | "premature_complete";
|
|
91
|
+
export type UltragoalNudgeTargetKind = "story" | "final_aggregate_receipt";
|
|
92
|
+
|
|
93
|
+
export interface UltragoalNudgeLedgerEvent extends UltragoalLedgerEvent {
|
|
94
|
+
event: "nudge";
|
|
95
|
+
goalId: string;
|
|
96
|
+
targetKind: UltragoalNudgeTargetKind;
|
|
97
|
+
surface: UltragoalNudgeSurface;
|
|
98
|
+
attempt: number;
|
|
99
|
+
budget: number;
|
|
100
|
+
reason: string;
|
|
101
|
+
currentGoalObjective?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface UltragoalNudgeTarget {
|
|
105
|
+
goalId: string;
|
|
106
|
+
targetKind: UltragoalNudgeTargetKind;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type UltragoalNudgeOutcome =
|
|
110
|
+
| {
|
|
111
|
+
nudged: true;
|
|
112
|
+
attempt: number;
|
|
113
|
+
budget: number;
|
|
114
|
+
goalId: string;
|
|
115
|
+
targetKind: UltragoalNudgeTargetKind;
|
|
116
|
+
event: UltragoalNudgeLedgerEvent;
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
nudged: false;
|
|
120
|
+
exhausted: true;
|
|
121
|
+
count: number;
|
|
122
|
+
budget: number;
|
|
123
|
+
goalId: string;
|
|
124
|
+
targetKind: UltragoalNudgeTargetKind;
|
|
125
|
+
}
|
|
126
|
+
| { nudged: false; inactive: true; reason: string };
|
|
127
|
+
|
|
83
128
|
export interface UltragoalPaths {
|
|
84
129
|
dir: string;
|
|
85
130
|
briefPath: string;
|
|
@@ -95,6 +140,11 @@ export interface UltragoalStatusSummary {
|
|
|
95
140
|
currentGoal?: UltragoalGoal;
|
|
96
141
|
counts: Record<UltragoalGoalStatus, number>;
|
|
97
142
|
goals: UltragoalGoal[];
|
|
143
|
+
nudgeBudget?: number;
|
|
144
|
+
nudgeCount?: number;
|
|
145
|
+
nudgeRemaining?: number;
|
|
146
|
+
nudgeGoalId?: string;
|
|
147
|
+
nudgeTargetKind?: UltragoalNudgeTargetKind;
|
|
98
148
|
}
|
|
99
149
|
|
|
100
150
|
export interface UltragoalCommandResult {
|
|
@@ -220,6 +270,156 @@ export async function readUltragoalLedger(cwd: string, sessionId?: string | null
|
|
|
220
270
|
}
|
|
221
271
|
}
|
|
222
272
|
|
|
273
|
+
export const DEFAULT_ULTRAGOAL_NUDGE_BUDGET = 10;
|
|
274
|
+
|
|
275
|
+
/** Pure: count ledger `nudge` rows for an exact goalId. */
|
|
276
|
+
export function countUltragoalNudges(ledger: readonly UltragoalLedgerEvent[], goalId: string): number {
|
|
277
|
+
return ledger.filter(event => event.event === "nudge" && event.goalId === goalId).length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseNudgeBudgetValue(value: unknown): number | null {
|
|
281
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 ? value : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readSettingsNudgeBudget(settingsPath: string): Promise<number | null> {
|
|
285
|
+
try {
|
|
286
|
+
const raw = await Bun.file(settingsPath).text();
|
|
287
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
288
|
+
// Support both the flat dotted key and a nested skc.ultragoal.nudgeBudget shape.
|
|
289
|
+
const flat = parseNudgeBudgetValue(parsed["skc.ultragoal.nudgeBudget"]);
|
|
290
|
+
if (flat !== null) return flat;
|
|
291
|
+
const skc = parsed.skc;
|
|
292
|
+
if (skc && typeof skc === "object") {
|
|
293
|
+
const ultragoal = (skc as Record<string, unknown>).ultragoal;
|
|
294
|
+
if (ultragoal && typeof ultragoal === "object") {
|
|
295
|
+
return parseNudgeBudgetValue((ultragoal as Record<string, unknown>).nudgeBudget);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the per-story nudge budget. Project `./.skc/settings.json` overrides the
|
|
306
|
+
* user settings (`$SKC_CONFIG_DIR/settings.json` or `~/.skc/settings.json`), else the
|
|
307
|
+
* default. Mirrors the `skc.deepInterview.ambiguityThreshold` user+project precedence.
|
|
308
|
+
*/
|
|
309
|
+
export async function resolveUltragoalNudgeBudget(cwd: string): Promise<{ budget: number; source: string }> {
|
|
310
|
+
const projectPath = path.join(skcRoot(cwd), "settings.json");
|
|
311
|
+
const project = await readSettingsNudgeBudget(projectPath);
|
|
312
|
+
if (project !== null) return { budget: project, source: projectPath };
|
|
313
|
+
const userDir = process.env.SKC_CONFIG_DIR?.trim() || path.join(os.homedir(), ".skc");
|
|
314
|
+
const userPath = path.join(userDir, "settings.json");
|
|
315
|
+
const user = await readSettingsNudgeBudget(userPath);
|
|
316
|
+
if (user !== null) return { budget: user, source: userPath };
|
|
317
|
+
return { budget: DEFAULT_ULTRAGOAL_NUDGE_BUDGET, source: "default" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Pure canonical selector shared by guards and status so `nudgeGoalId` can never
|
|
322
|
+
* diverge between what a guard consumes and what status displays. Prefers the active
|
|
323
|
+
* current-goal objective, then active > pending > failed (matching `chooseNextGoal`),
|
|
324
|
+
* then the aggregate final-receipt target when all stories are complete but the
|
|
325
|
+
* aggregate run still needs a final receipt. Returns null for verified-complete or
|
|
326
|
+
* absent/unrelated plans.
|
|
327
|
+
*/
|
|
328
|
+
export function selectUltragoalNudgeTarget(
|
|
329
|
+
plan: UltragoalPlan,
|
|
330
|
+
options: { currentGoalObjective?: string; retryFailed?: boolean } = {},
|
|
331
|
+
): UltragoalNudgeTarget | null {
|
|
332
|
+
const objective = options.currentGoalObjective?.trim();
|
|
333
|
+
if (objective) {
|
|
334
|
+
const matched = plan.goals.find(
|
|
335
|
+
goal => goal.objective.trim() === objective && SCHEDULABLE_STATUSES.has(goal.status),
|
|
336
|
+
);
|
|
337
|
+
if (matched) return { goalId: matched.id, targetKind: "story" };
|
|
338
|
+
}
|
|
339
|
+
const next = chooseNextGoal(plan, options.retryFailed === true);
|
|
340
|
+
if (next) return { goalId: next.id, targetKind: "story" };
|
|
341
|
+
const completion = getUltragoalRunCompletionState(plan, { retryFailed: options.retryFailed });
|
|
342
|
+
if (completion.needsFinalAggregateReceipt) {
|
|
343
|
+
const required = requiredUltragoalGoals(plan);
|
|
344
|
+
const finalGoal = required.at(-1);
|
|
345
|
+
if (finalGoal) return { goalId: finalGoal.id, targetKind: "final_aggregate_receipt" };
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Atomic consuming writer. Locks the ledger path, rereads + counts nudge rows for the
|
|
352
|
+
* target story, and appends exactly one `nudge` row inside the same critical section
|
|
353
|
+
* only while budget remains. Reuses the lockless `appendLedger` inside the lock (it
|
|
354
|
+
* does not acquire a conflicting lock), so concurrent guarded attempts cannot both
|
|
355
|
+
* observe `count = budget - 1` and overshoot the budget.
|
|
356
|
+
*/
|
|
357
|
+
export async function recordUltragoalNudgeIfBudgetRemaining(input: {
|
|
358
|
+
cwd: string;
|
|
359
|
+
sessionId?: string | null;
|
|
360
|
+
target: UltragoalNudgeTarget;
|
|
361
|
+
surface: UltragoalNudgeSurface;
|
|
362
|
+
budget: number;
|
|
363
|
+
reason: string;
|
|
364
|
+
currentGoalObjective?: string;
|
|
365
|
+
}): Promise<UltragoalNudgeOutcome> {
|
|
366
|
+
const { cwd, sessionId, target, surface, budget, reason } = input;
|
|
367
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
368
|
+
return {
|
|
369
|
+
nudged: false,
|
|
370
|
+
exhausted: true,
|
|
371
|
+
count: 0,
|
|
372
|
+
budget: Math.max(0, budget | 0),
|
|
373
|
+
goalId: target.goalId,
|
|
374
|
+
targetKind: target.targetKind,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const resolvedSessionId =
|
|
378
|
+
sessionId?.trim() || resolveSkcSessionForWrite(cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
|
|
379
|
+
const paths = getUltragoalPaths(cwd, resolvedSessionId);
|
|
380
|
+
return withWorkflowStateLock(
|
|
381
|
+
paths.ledgerPath,
|
|
382
|
+
async () => {
|
|
383
|
+
const ledger = await readUltragoalLedger(cwd, resolvedSessionId);
|
|
384
|
+
const count = countUltragoalNudges(ledger, target.goalId);
|
|
385
|
+
if (count >= budget) {
|
|
386
|
+
return {
|
|
387
|
+
nudged: false,
|
|
388
|
+
exhausted: true,
|
|
389
|
+
count,
|
|
390
|
+
budget,
|
|
391
|
+
goalId: target.goalId,
|
|
392
|
+
targetKind: target.targetKind,
|
|
393
|
+
} as const;
|
|
394
|
+
}
|
|
395
|
+
const attempt = count + 1;
|
|
396
|
+
const entry = (await appendLedger(
|
|
397
|
+
cwd,
|
|
398
|
+
{
|
|
399
|
+
event: "nudge",
|
|
400
|
+
goalId: target.goalId,
|
|
401
|
+
targetKind: target.targetKind,
|
|
402
|
+
surface,
|
|
403
|
+
attempt,
|
|
404
|
+
budget,
|
|
405
|
+
reason,
|
|
406
|
+
...(input.currentGoalObjective ? { currentGoalObjective: input.currentGoalObjective } : {}),
|
|
407
|
+
},
|
|
408
|
+
resolvedSessionId,
|
|
409
|
+
)) as UltragoalNudgeLedgerEvent;
|
|
410
|
+
return {
|
|
411
|
+
nudged: true,
|
|
412
|
+
attempt,
|
|
413
|
+
budget,
|
|
414
|
+
goalId: target.goalId,
|
|
415
|
+
targetKind: target.targetKind,
|
|
416
|
+
event: entry,
|
|
417
|
+
} as const;
|
|
418
|
+
},
|
|
419
|
+
{ cwd },
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
223
423
|
async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null): Promise<void> {
|
|
224
424
|
const resolvedSessionId =
|
|
225
425
|
sessionId?.trim() || resolveSkcSessionForWrite(cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
|
|
@@ -525,6 +725,20 @@ export async function getUltragoalStatus(cwd: string, sessionId?: string | null)
|
|
|
525
725
|
else if (counts.active > 0) status = "active";
|
|
526
726
|
else if (counts.failed > 0) status = "failed";
|
|
527
727
|
else if (counts.blocked > 0 || counts.review_blocked > 0) status = "blocked";
|
|
728
|
+
const nudgeTarget = selectUltragoalNudgeTarget(plan, { currentGoalObjective: currentGoal?.objective });
|
|
729
|
+
let nudgeFields: Partial<UltragoalStatusSummary> = {};
|
|
730
|
+
if (nudgeTarget) {
|
|
731
|
+
const { budget } = await resolveUltragoalNudgeBudget(cwd);
|
|
732
|
+
const ledger = await readUltragoalLedger(cwd, resolvedSessionId);
|
|
733
|
+
const nudgeCount = countUltragoalNudges(ledger, nudgeTarget.goalId);
|
|
734
|
+
nudgeFields = {
|
|
735
|
+
nudgeBudget: budget,
|
|
736
|
+
nudgeCount,
|
|
737
|
+
nudgeRemaining: Math.max(0, budget - nudgeCount),
|
|
738
|
+
nudgeGoalId: nudgeTarget.goalId,
|
|
739
|
+
nudgeTargetKind: nudgeTarget.targetKind,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
528
742
|
return {
|
|
529
743
|
exists: true,
|
|
530
744
|
status,
|
|
@@ -533,6 +747,7 @@ export async function getUltragoalStatus(cwd: string, sessionId?: string | null)
|
|
|
533
747
|
currentGoal,
|
|
534
748
|
counts,
|
|
535
749
|
goals: plan.goals,
|
|
750
|
+
...nudgeFields,
|
|
536
751
|
};
|
|
537
752
|
}
|
|
538
753
|
export function buildUltragoalHudSummary(
|
|
@@ -3847,6 +4062,11 @@ async function reconcileUltragoalState(cwd: string): Promise<void> {
|
|
|
3847
4062
|
goals_path: summary.paths.goalsPath,
|
|
3848
4063
|
};
|
|
3849
4064
|
if (summary.skcObjective) payload.skc_objective = summary.skcObjective;
|
|
4065
|
+
if (summary.nudgeBudget !== undefined) payload.nudge_budget = summary.nudgeBudget;
|
|
4066
|
+
if (summary.nudgeCount !== undefined) payload.nudge_count = summary.nudgeCount;
|
|
4067
|
+
if (summary.nudgeRemaining !== undefined) payload.nudge_remaining = summary.nudgeRemaining;
|
|
4068
|
+
if (summary.nudgeGoalId !== undefined) payload.nudge_goal_id = summary.nudgeGoalId;
|
|
4069
|
+
if (summary.nudgeTargetKind !== undefined) payload.nudge_target_kind = summary.nudgeTargetKind;
|
|
3850
4070
|
const ledgerText = await Bun.file(summary.paths.ledgerPath)
|
|
3851
4071
|
.text()
|
|
3852
4072
|
.catch(() => "");
|
|
@@ -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 "@sayknow-cli/utils";
|
|
6
|
+
|
|
7
|
+
/** Prefix of every computer-use screenshot fallback directory created under the OS temp dir. */
|
|
8
|
+
export const SCREENSHOT_FALLBACK_DIR_PREFIX = "skc-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
|
+
* `skc-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 "@sayknow-cli/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(), "skc-computer-screenshots-"));
|
|
549
550
|
await fs.chmod(dir, 0o700);
|
|
551
|
+
markScreenshotFallbackDirCreatedForGc();
|
|
550
552
|
return dir;
|
|
551
553
|
}
|
|
552
554
|
|