@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.
Files changed (65) hide show
  1. package/dist/types/config/model-profiles.d.ts +2 -2
  2. package/dist/types/config/model-registry.d.ts +3 -3
  3. package/dist/types/config/models-config-schema.d.ts +0 -5
  4. package/dist/types/config/settings-schema.d.ts +78 -9
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/skc-runtime/state-renderer.d.ts +5 -0
  7. package/dist/types/skc-runtime/ultragoal-guard.d.ts +37 -1
  8. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +80 -0
  9. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  10. package/dist/types/tools/computer-gc.d.ts +23 -0
  11. package/dist/types/tools/cron.d.ts +36 -61
  12. package/dist/types/tools/index.d.ts +0 -1
  13. package/dist/types/tools/resource-gc.d.ts +54 -0
  14. package/dist/types/web/search/index.d.ts +1 -0
  15. package/dist/types/web/search/providers/utils.d.ts +11 -4
  16. package/package.json +7 -7
  17. package/scripts/verify-insane-vendor.ts +5 -0
  18. package/src/cli/args.ts +0 -1
  19. package/src/cli/fast-help.ts +0 -1
  20. package/src/cli/plugin-cli.ts +1 -1
  21. package/src/cli/web-search-cli.ts +5 -0
  22. package/src/config/model-profile-activation.ts +7 -1
  23. package/src/config/model-profiles.ts +3 -4
  24. package/src/config/model-registry.ts +3 -6
  25. package/src/config/models-config-schema.ts +1 -1
  26. package/src/config/settings-schema.ts +80 -10
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +0 -12
  29. package/src/goals/tools/goal-tool.ts +14 -1
  30. package/src/internal-urls/docs-index.generated.ts +3 -4
  31. package/src/modes/components/model-selector.ts +9 -1
  32. package/src/modes/controllers/selector-controller.ts +6 -0
  33. package/src/prompts/system/system-prompt.md +16 -20
  34. package/src/prompts/tools/cron.md +5 -3
  35. package/src/prompts/tools/read.md +1 -1
  36. package/src/sdk.ts +5 -0
  37. package/src/session/agent-session.ts +44 -0
  38. package/src/skc-runtime/state-renderer.ts +13 -0
  39. package/src/skc-runtime/ultragoal-guard.ts +167 -0
  40. package/src/skc-runtime/ultragoal-runtime.ts +221 -1
  41. package/src/tools/browser/tab-supervisor.ts +86 -2
  42. package/src/tools/computer-gc.ts +66 -0
  43. package/src/tools/computer.ts +2 -0
  44. package/src/tools/cron.ts +75 -112
  45. package/src/tools/index.ts +2 -8
  46. package/src/tools/read.ts +25 -55
  47. package/src/tools/renderers.ts +0 -2
  48. package/src/tools/resource-gc.ts +291 -0
  49. package/src/tools/ultragoal-ask-guard.ts +7 -1
  50. package/src/web/search/index.ts +1 -0
  51. package/src/web/search/providers/utils.ts +22 -5
  52. package/vendor/insane-search/MANIFEST.json +5 -1
  53. package/vendor/insane-search/engine/__init__.py +14 -0
  54. package/vendor/insane-search/engine/content_safety.py +151 -0
  55. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  56. package/vendor/insane-search/engine/tests/test_u1.py +28 -0
  57. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  58. package/vendor/insane-search/engine/transport.py +3 -2
  59. package/vendor/insane-search/engine/validators.py +14 -0
  60. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  61. package/dist/types/tools/inspect-image.d.ts +0 -31
  62. package/src/prompts/tools/inspect-image-system.md +0 -20
  63. package/src/prompts/tools/inspect-image.md +0 -32
  64. package/src/tools/inspect-image-renderer.ts +0 -103
  65. 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 { appendJsonl, persistedStateRevision, writeArtifact, writeGuardedJsonAtomic } from "./state-writer";
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
- 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 "@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
+ }
@@ -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