mixdog 0.9.38 → 0.9.40

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 (124) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +176 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -8,12 +8,13 @@ import * as fsp from 'fs/promises';
8
8
  import { randomBytes } from 'crypto';
9
9
  import { join } from 'path';
10
10
  import { Worker } from 'worker_threads';
11
- import { getPluginData } from '../config.mjs';
11
+ import { getPluginData, loadConfig } from '../config.mjs';
12
12
  import { isAgentOwner } from '../agent-owner.mjs';
13
13
  import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
14
14
  import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
15
15
  import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
16
16
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../../../../lib/mixdog-debug.cjs';
17
+ import { resolveAgentTerminalReapMs } from '../../../../session-runtime/config-helpers.mjs';
17
18
  import {
18
19
  summaryIndexPath,
19
20
  _sessionSummary,
@@ -288,7 +289,10 @@ function _getOrSpawnWorker() {
288
289
  // (the originating call plus any that coalesced onto it before it was
289
290
  // posted). A supersede never lands here as a rejection — only a real
290
291
  // worker failure does.
291
- if (ok) { for (const w of waiters) w.resolve(); }
292
+ if (ok) {
293
+ clearSessionSaveError(id);
294
+ for (const w of waiters) w.resolve();
295
+ }
292
296
  else {
293
297
  const e = new Error(`[session-store] worker save failed: ${error}`);
294
298
  for (const w of waiters) w.reject(e);
@@ -440,6 +444,7 @@ function _doSaveSync(payload) {
440
444
  }
441
445
  _renameWithRetrySync(tmp, target);
442
446
  _upsertSessionSummary(session);
447
+ clearSessionSaveError(id);
443
448
  } catch (err) {
444
449
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
445
450
  throw err;
@@ -579,6 +584,7 @@ async function _doSave(payload) {
579
584
  }
580
585
  _renameWithRetrySync(tmp, target);
581
586
  _upsertSessionSummary(session);
587
+ clearSessionSaveError(id);
582
588
  } catch (err) {
583
589
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
584
590
  _savePending.delete(id);
@@ -600,8 +606,37 @@ export function markSessionClosed(id, reason = 'manual') {
600
606
  _clearDebounce(id);
601
607
  const existing = loadSession(id);
602
608
  if (!existing) return null;
603
- const newGen = (typeof existing.generation === 'number' ? existing.generation : 0) + 1;
604
- const tombstone = { ...existing, closed: true, closedReason: reason, status: 'closed', generation: newGen, updatedAt: Date.now() };
609
+ // Re-close idempotence: a session that is ALREADY tombstoned keeps its
610
+ // ORIGINAL close time (updatedAt) and generation. The old code refreshed
611
+ // updatedAt=Date.now() on every call, so the 5-min idle sweep re-closing a
612
+ // stale summary row reset the tombstone age each cycle — tombstones never
613
+ // matured past the sweep threshold (immortality loop). Preserving the
614
+ // original close time lets the age accumulate so the tombstone sweep can
615
+ // reclaim it.
616
+ //
617
+ // The alreadyClosed / original-close-time / generation decision MUST come
618
+ // from the ON-DISK JSON, read cache-bypassing — NOT from loadSession(),
619
+ // which can serve a stale in-memory OPEN payload (a pending debounced save
620
+ // or a _liveSessions entry) after a late save. Deciding off that stale open
621
+ // copy would make a re-close of an already-tombstoned session look like a
622
+ // FIRST close and reset updatedAt+generation, resurrecting the exact
623
+ // immortality refresh this guard prevents. The disk file is the
624
+ // authoritative tombstone state.
625
+ let onDisk = null;
626
+ try { onDisk = JSON.parse(readFileSync(sessionPath(id), 'utf-8')); }
627
+ catch { onDisk = null; }
628
+ const alreadyClosed = onDisk
629
+ ? (onDisk.closed === true || onDisk.status === 'closed')
630
+ : (existing.closed === true);
631
+ // When the on-disk copy is already closed, base the (idempotent) tombstone
632
+ // rewrite on IT rather than on `existing`, so a stale open in-memory
633
+ // payload can never clobber the persisted tombstone's content/fields.
634
+ const base = (alreadyClosed && onDisk) ? onDisk : existing;
635
+ const closeTime = (alreadyClosed && typeof base.updatedAt === 'number' && base.updatedAt > 0)
636
+ ? base.updatedAt
637
+ : Date.now();
638
+ const newGen = (typeof base.generation === 'number' ? base.generation : 0) + (alreadyClosed ? 0 : 1);
639
+ const tombstone = { ...base, closed: true, closedReason: alreadyClosed ? (base.closedReason || reason) : reason, status: 'closed', generation: newGen, updatedAt: closeTime };
605
640
  // Bypass the queue + guard — this IS the tombstone write.
606
641
  const target = sessionPath(id);
607
642
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
@@ -613,6 +648,7 @@ export function markSessionClosed(id, reason = 'manual') {
613
648
  return null;
614
649
  }
615
650
  _savePending.delete(id);
651
+ clearSessionSaveError(id);
616
652
  _clearLiveSession(id);
617
653
  _deleteHeartbeat(id);
618
654
  _upsertSessionSummary(tombstone);
@@ -622,7 +658,10 @@ export function markSessionClosed(id, reason = 'manual') {
622
658
  // it reflects the session's full lifetime including the close turn.
623
659
  try {
624
660
  const _dataDir = getPluginData();
625
- if (_dataDir) {
661
+ // Emit the close metric only on the FIRST close — a re-close of an
662
+ // already-tombstoned session is a no-op idempotent write and must not
663
+ // spam the close log or double-count lifetimes.
664
+ if (_dataDir && !alreadyClosed) {
626
665
  const _ts = new Date().toISOString();
627
666
  const _lifeMs = (typeof existing.createdAt === 'number' && existing.createdAt > 0)
628
667
  ? (tombstone.updatedAt - existing.createdAt)
@@ -669,6 +708,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
669
708
  return null;
670
709
  }
671
710
  _savePending.delete(id);
711
+ clearSessionSaveError(id);
672
712
  _clearLiveSession(id);
673
713
  _deleteHeartbeat(id);
674
714
  _upsertSessionSummary(detached);
@@ -715,6 +755,7 @@ export function deleteSession(id, options = {}) {
715
755
  catch { /* fall through to .hb cleanup */ }
716
756
  }
717
757
  _deleteHeartbeat(id);
758
+ if (removed || !existsSync(path)) clearSessionSaveError(id);
718
759
  // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
719
760
  // rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
720
761
  // summary index once PER DELETION. They batch the index update themselves.
@@ -722,16 +763,24 @@ export function deleteSession(id, options = {}) {
722
763
  return removed;
723
764
  }
724
765
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
725
- // Completed agents (idle/done/error) live until terminal reap - must
726
- // match TERMINAL_REAP_MS / _scheduleBridgeReap (3_600_000) in index.mjs and
727
- // agent stall watchdog, so the store sweep is the durable 1h reaper.
728
- const AGENT_TERMINAL_TTL_MS = 60 * 60 * 1000;
729
766
  const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
730
767
  // Hard wall-clock ceiling for sessions stuck in status='running'. The
731
768
  // stream-watchdog should abort stalled streams within ~120s, but if it misses
732
769
  // one (process crash, watchdog not started, provider never returned), this
733
770
  // backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
734
771
  const RUNNING_STALL_MS = 10 * 60 * 1000;
772
+ // Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
773
+ // closes sessions with { tombstone:false } — the runtime detaches but the
774
+ // session JSON stays open/resumable and is never lifecycle-closed, so without
775
+ // a cap the sessions/ dir grows without bound (observed 782 open files). The
776
+ // sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
777
+ // newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
778
+ // sessions — explicit USER-owned conversations are never auto-pruned (deleting
779
+ // a user's history, including the current foreground session which is idle
780
+ // during a gated sweep, is unacceptable). A session with a live runtime entry
781
+ // (options.isSessionLive) is additionally protected as defense-in-depth.
782
+ const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
783
+ const RESUMABLE_OPEN_MAX_COUNT = 300;
735
784
 
736
785
  export function listStoredSessions() {
737
786
  const dir = getStoreDir();
@@ -800,12 +849,44 @@ export function sweepStaleSessions(ttlMs, options = {}) {
800
849
  }
801
850
  const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
802
851
  const sweepIdle = options.sweepIdle !== false;
852
+ let terminalReapConfig = null;
853
+ try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
803
854
  const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
804
855
  const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
856
+ // Retention cap for resumable open sessions runs only on the idle sweep
857
+ // (never on a tombstone-only pass). isSessionLive protects the current /
858
+ // actively-running sessions from being pruned by the retention cap.
859
+ const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
860
+ const retainOpen = sweepIdle && options.retainOpenSessions !== false;
861
+ const _optAge = Number(options.openMaxAgeMs);
862
+ const _optCount = Number(options.openMaxCount);
863
+ const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
864
+ const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
805
865
  const dir = getStoreDir();
806
866
  if (!existsSync(dir))
807
867
  return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
808
- const summaries = listStoredSessionSummaries();
868
+ // Reconcile the index-derived candidate set with a direct directory scan:
869
+ // the summary index is a best-effort sidecar that can lag far behind disk
870
+ // (thousands of on-disk .json files may be absent from a smaller index).
871
+ // Any such orphan closed+mature tombstone would otherwise be unreachable by
872
+ // this sweep and accumulate forever. Union the index rows with every
873
+ // on-disk .json id, deduped by id; synthetic { id } rows are sufficient
874
+ // because the loop below re-reads all lifecycle truth from disk. This stays
875
+ // sweep-local and does NOT change listStoredSessionSummaries for other
876
+ // callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
877
+ const indexRows = listStoredSessionSummaries();
878
+ const summaries = indexRows;
879
+ try {
880
+ const seen = new Set();
881
+ for (const row of indexRows) { if (row?.id) seen.add(row.id); }
882
+ for (const f of readdirSync(dir)) {
883
+ if (!f.endsWith('.json')) continue;
884
+ const id = f.slice(0, -5);
885
+ if (!id || seen.has(id)) continue;
886
+ seen.add(id);
887
+ summaries.push({ id });
888
+ }
889
+ } catch { /* dir scan failure — fall back to index rows only */ }
809
890
  const now = Date.now();
810
891
  let cleaned = 0;
811
892
  let remaining = 0;
@@ -813,6 +894,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
813
894
  const details = [];
814
895
  const tombstoneDetails = [];
815
896
  const tombstoneErrors = [];
897
+ // Retention-cap bookkeeping: collect surviving open (non-tombstone)
898
+ // sessions here, then prune oldest-first after the main loop.
899
+ const openCandidates = [];
900
+ let openPruned = 0;
901
+ const openPrunedDetails = [];
816
902
  for (const row of summaries) {
817
903
  try {
818
904
  if (!row?.id) continue;
@@ -828,38 +914,116 @@ export function sweepStaleSessions(ttlMs, options = {}) {
828
914
  const hbPath = join(dir, `${row.id}.hb`);
829
915
  if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
830
916
  } catch { /* .hb unavailable — fall back to JSON fields */ }
831
- const freshnessGateMs = sweepIdle ? maxAge : (sweepTombstones ? tombstoneMaxAgeMs : 0);
832
- const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
833
- if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
834
- remaining++;
835
- continue;
917
+ // Truth source: the summary index is a deferred/best-effort sidecar,
918
+ // so a row can still claim status='idle'/open while the session JSON
919
+ // was already tombstoned. Read the real session JSON BEFORE the
920
+ // freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
921
+ // state — otherwise idle-sweep re-closes an already-closed session via
922
+ // markSessionClosed (which, pre-fix, reset the tombstone age every
923
+ // 5-min cycle → immortality loop).
924
+ let raw = null;
925
+ try { raw = readFileSync(jsonPath, 'utf-8'); }
926
+ catch { /* racing unlink / transient read failure */ }
927
+ let actual = null;
928
+ let diskClosed;
929
+ if (raw == null) {
930
+ diskClosed = (row.closed === true || row.status === 'closed');
931
+ } else {
932
+ // Cheap top-level scan avoids allocating the whole messages array
933
+ // just to read the closed flag for the (common) fresh open
934
+ // session the gate will skip; full-parse only when the scan can't
935
+ // resolve the top-level flag.
936
+ const scan = scanTopLevelLifecycle(raw);
937
+ if (scan && typeof scan.closed === 'boolean') {
938
+ diskClosed = scan.closed;
939
+ } else {
940
+ try { actual = JSON.parse(raw); } catch { actual = null; }
941
+ diskClosed = actual
942
+ ? (actual.closed === true || actual.status === 'closed')
943
+ : (row.closed === true || row.status === 'closed');
944
+ }
836
945
  }
837
- // Already-closed tombstones are handled here before heartbeat
838
- // sidecar checks; they do not need liveness probing.
839
- if (row.closed === true || row.status === 'closed') {
840
- if (sweepTombstones) {
841
- const updated = Number(row.updatedAt);
842
- const age = now - updated;
843
- if (Number.isFinite(updated) && age >= tombstoneMaxAgeMs) {
844
- try {
845
- if (deleteSession(row.id, { deferSummaryUpdate: true })) {
846
- tombstonesCleaned++;
847
- tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
848
- continue;
849
- }
850
- } catch (err) {
851
- tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
852
- remaining++;
946
+ if (diskClosed) {
947
+ // Closed sessions are EXEMPT from the freshness gate: a tombstone
948
+ // whose file/hb mtime keeps getting bumped would otherwise stay
949
+ // perpetually "fresh" and never mature. Maturity is governed ONLY
950
+ // by the ORIGINAL close time (disk updatedAt, not row.updatedAt
951
+ // which a stale row may carry from before the close).
952
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
953
+ const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
954
+ const age = now - closedAt;
955
+ if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
956
+ try {
957
+ if (deleteSession(row.id, { deferSummaryUpdate: true })) {
958
+ tombstonesCleaned++;
959
+ tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
853
960
  continue;
854
961
  }
962
+ } catch (err) {
963
+ tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
964
+ remaining++;
965
+ continue;
855
966
  }
856
967
  }
968
+ // Repair a stale summary row that still claimed the session was
969
+ // open: reflect the real closed state so the next sweep sees the
970
+ // correct closed=true/updatedAt and never re-closes it.
971
+ if (actual && !(row.closed === true || row.status === 'closed')) {
972
+ try { _upsertSessionSummary(actual); } catch { /* best-effort */ }
973
+ }
857
974
  remaining++;
858
975
  continue;
859
976
  }
977
+ // Parse the open record before its freshness gate: completed agents
978
+ // use their provider's Advanced terminal duration rather than the
979
+ // general sweep cadence. A short provider override must therefore
980
+ // not be hidden behind the default 5-minute gate.
981
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
982
+ const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
983
+ ? actual.owner : row.owner;
984
+ const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
985
+ const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
986
+ const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
987
+ && AGENT_TERMINAL_STATUSES.has(gateStatus);
988
+ const terminalReapMsForGate = isCompletedAgentForGate
989
+ ? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
990
+ : null;
991
+ if (isCompletedAgentForGate && terminalReapMsForGate == null) {
992
+ remaining++;
993
+ continue;
994
+ }
995
+ // Freshness gate — OPEN sessions only (closed sessions handled and
996
+ // `continue`d above). Recently-touched open sessions are skipped
997
+ // cheaply here.
998
+ const freshnessGateMs = sweepIdle
999
+ ? (terminalReapMsForGate ?? maxAge)
1000
+ : (sweepTombstones ? tombstoneMaxAgeMs : 0);
1001
+ const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
1002
+ if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
1003
+ remaining++;
1004
+ continue;
1005
+ }
1006
+ // Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
1007
+ // possibly stale) summary row for every open/idle liveness and
1008
+ // ownership decision below — a stale row must not close or prune the
1009
+ // wrong session. Full-parse here if the cheap scan skipped it.
1010
+ if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
1011
+ const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
1012
+ ? actual.owner : row.owner;
1013
+ const ownerRef = { owner: effOwner };
1014
+ const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
1015
+ const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
1016
+ const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
1017
+ const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
1018
+ const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
1019
+ const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
860
1020
  // Sweep agent-owned and ownerless (legacy) sessions; skip explicit
861
- // user sessions before touching heartbeat sidecars.
862
- if (typeof row.owner === 'string' && row.owner.length > 0 && !isAgentOwner(row)) {
1021
+ // user sessions before touching heartbeat sidecars. USER-owned
1022
+ // conversations are NEVER added to the retention-cap candidate set
1023
+ // the cap must not auto-delete user history (nor the current
1024
+ // foreground session, which is idle during a gated sweep). Only the
1025
+ // ephemeral agent/ownerless sessions below feed the cap.
1026
+ if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
863
1027
  remaining++;
864
1028
  continue;
865
1029
  }
@@ -870,18 +1034,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
870
1034
  // Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
871
1035
  // serialising the full JSON, so it reflects true liveness more
872
1036
  // accurately than the JSON timestamp fields.
873
- let lastActive = row.lastHeartbeatAt || row.updatedAt || row.createdAt || 0;
1037
+ let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
874
1038
  if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
875
1039
  // Running sessions are normally reaped by the stream-watchdog
876
1040
  // within ~120s. Skip them here unless they've been silent past
877
1041
  // RUNNING_STALL_MS, at which point they are treated as zombies.
878
- if (row.status === 'running' && now - lastActive <= RUNNING_STALL_MS) {
1042
+ if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
879
1043
  remaining++;
880
1044
  continue;
881
1045
  }
882
- const isCompletedAgent = isAgentOwner(row)
883
- && AGENT_TERMINAL_STATUSES.has(row.status);
884
- const sessionMaxAge = isCompletedAgent ? AGENT_TERMINAL_TTL_MS : maxAge;
1046
+ const isCompletedAgent = isAgentOwner(ownerRef)
1047
+ && AGENT_TERMINAL_STATUSES.has(effStatus);
1048
+ const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
1049
+ const sessionMaxAge = terminalReapMs ?? maxAge;
885
1050
  if (now - lastActive > sessionMaxAge) {
886
1051
  try { markSessionClosed(row.id, 'idle-sweep'); }
887
1052
  catch (err) {
@@ -891,16 +1056,40 @@ export function sweepStaleSessions(ttlMs, options = {}) {
891
1056
  cleaned++;
892
1057
  details.push({
893
1058
  id: row.id,
894
- owner: row.owner || 'unknown',
1059
+ owner: effOwner || 'unknown',
895
1060
  idleMinutes: Math.round((now - lastActive) / 60000),
896
- bashSessionId: row.implicitBashSessionId || null,
1061
+ bashSessionId: effBashId,
897
1062
  });
898
1063
  } else {
1064
+ if (retainOpen) openCandidates.push({ id: row.id, lastActive });
899
1065
  remaining++;
900
1066
  }
901
1067
  }
902
1068
  catch { /* skip corrupt */ }
903
1069
  }
1070
+ // ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
1071
+ // Newest-first: keep the most recent openMaxCount, prune anything older than
1072
+ // openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
1073
+ // are never pruned but still occupy a kept slot.
1074
+ if (retainOpen && openCandidates.length > 0) {
1075
+ openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
1076
+ let kept = 0;
1077
+ for (const c of openCandidates) {
1078
+ if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
1079
+ const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
1080
+ const overCount = kept >= openMaxCount;
1081
+ if (!tooOld && !overCount) { kept++; continue; }
1082
+ try {
1083
+ if (deleteSession(c.id, { deferSummaryUpdate: true })) {
1084
+ openPruned++;
1085
+ openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
1086
+ if (remaining > 0) remaining--;
1087
+ } else {
1088
+ kept++;
1089
+ }
1090
+ } catch { kept++; }
1091
+ }
1092
+ }
904
1093
  // Orphan .hb reap: a heartbeat sidecar whose .json no longer exists is dead
905
1094
  // weight once it is also stale (older than maxAge) — the session JSON was
906
1095
  // swept/closed but the .hb lingered (a pre-fix orphaned heartbeat). The
@@ -920,11 +1109,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
920
1109
  // read-modify-write for the whole sweep instead of one per deleted id
921
1110
  // (the index is multi-MB at scale; per-id rewrites made large sweeps
922
1111
  // quadratic and stalled boot for seconds).
923
- if (tombstoneDetails.length > 0) {
1112
+ if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
924
1113
  try {
925
- const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
1114
+ const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
926
1115
  _pruneSummaryIndexIds(deletedIds);
927
1116
  } catch { /* summary index is best-effort */ }
928
1117
  }
929
- return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
1118
+ return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
930
1119
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'fs';
2
- import { readdir, rmdir, unlink, writeFile } from 'fs/promises';
2
+ import { readdir, rmdir, stat, unlink, writeFile } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { getPluginData } from '../config.mjs';
5
5
  import { normalizeOutputPath } from '../tools/builtin/path-utils.mjs';
@@ -11,6 +11,7 @@ const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
11
11
  const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
12
12
  const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
13
13
  export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
14
+ const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
14
15
 
15
16
  // Per-tool persistence limits mirror reference per-tool maxResultSizeChars
16
17
  // rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
@@ -147,6 +148,47 @@ export async function clearOffloadSession(sessionId) {
147
148
  } catch { /* best-effort */ }
148
149
  }
149
150
 
151
+ // Remove sidecars that no longer occur in the live transcript. A serialized
152
+ // path match is conservative: if messages cannot be serialized, or a path is
153
+ // mentioned anywhere in a message, retain the file rather than risk deleting
154
+ // one that can still be read by the model.
155
+ export async function pruneOffloadSession(sessionId, getMessages) {
156
+ if (!sessionId || typeof getMessages !== 'function') return;
157
+ const dir = join(getPluginData(), 'tool-results', safeSessionSegment(sessionId));
158
+ if (!existsSync(dir)) return;
159
+ let candidates;
160
+ try {
161
+ const entries = await readdir(dir);
162
+ candidates = (await Promise.all(entries
163
+ .filter((name) => name.endsWith('.txt'))
164
+ .map(async (name) => {
165
+ const filePath = join(dir, name);
166
+ try {
167
+ const fileStat = await stat(filePath);
168
+ if (Date.now() - fileStat.mtimeMs < OFFLOAD_PRUNE_MIN_AGE_MS) return null;
169
+ return { name, filePath };
170
+ } catch {
171
+ return null;
172
+ }
173
+ })
174
+ )).filter(Boolean);
175
+ } catch { /* best-effort */ }
176
+ if (!candidates) return;
177
+ let serialized;
178
+ try { serialized = JSON.stringify(getMessages()); } catch { return; }
179
+ const haystack = process.platform === 'win32' ? serialized.toLowerCase() : serialized;
180
+ await Promise.all(candidates
181
+ .filter(({ name, filePath }) => {
182
+ const normalizedPath = normalizeOutputPath(filePath);
183
+ const needles = [normalizedPath, name];
184
+ return !needles.some((needle) => {
185
+ const value = process.platform === 'win32' ? needle.toLowerCase() : needle;
186
+ return haystack.includes(value);
187
+ });
188
+ })
189
+ .map(({ filePath }) => unlink(filePath).catch(() => { /* best-effort */ })));
190
+ }
191
+
150
192
  export function isOffloadedToolResultText(text) {
151
193
  return typeof text === 'string' && text.startsWith(TOOL_RESULT_OFFLOAD_PREFIX);
152
194
  }
@@ -79,6 +79,11 @@ function clearGrepContextKeys(args, keys) {
79
79
  /** Lead-facing grep context: canonicalize aliases and clamp explicit values (overrides still apply). */
80
80
  export function applyGrepContextLeadPolicy(args) {
81
81
  if (!args || typeof args !== 'object' || Array.isArray(args)) return;
82
+ // Idempotent guard: the outer builtin guard (validateBuiltinArgs) and the
83
+ // executor (executeGrepTool) both call this on the SAME args object. The
84
+ // first pass folds aliases onto -A/-B/-C and clamps; a second pass would
85
+ // recompute the same folding for no effect. Skip once applied.
86
+ if (args._grepContextPolicyApplied) return;
82
87
  for (const [canonical, keys] of GREP_CONTEXT_KEY_GROUPS) {
83
88
  const found = firstNonZeroGrepContextArg(args, keys) || firstGrepContextArg(args, keys);
84
89
  if (!found) continue;
@@ -87,6 +92,12 @@ export function applyGrepContextLeadPolicy(args) {
87
92
  clearGrepContextKeys(args, keys);
88
93
  args[canonical] = shaped;
89
94
  }
95
+ // Non-enumerable so it never leaks into arg spreads, cache keys, or output.
96
+ try {
97
+ Object.defineProperty(args, '_grepContextPolicyApplied', {
98
+ value: true, enumerable: false, configurable: true, writable: true,
99
+ });
100
+ } catch { args._grepContextPolicyApplied = true; }
90
101
  }
91
102
 
92
103
  function isString(v) {
@@ -435,7 +446,7 @@ function guardGrep(a) {
435
446
  const grepMode = a.output_mode || a.mode;
436
447
  const hasExplicitCtx = ['-A', '-B', '-C', 'context'].some((k) => grepContextKeyPresent(a, k));
437
448
  const isCountOrFiles = grepMode === 'files_with_matches' || grepMode === 'count';
438
- if (!isCountOrFiles && (grepMode === 'content_with_context' || hasExplicitCtx)) {
449
+ if (!isCountOrFiles && (grepMode == null || grepMode === 'content_with_context' || hasExplicitCtx)) {
439
450
  const hl = Number(a.head_limit);
440
451
  if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
441
452
  a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
@@ -124,23 +124,31 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
124
124
  function _combineAbortSignals(sessionSignal, externalSignal) {
125
125
  const a = sessionSignal || null;
126
126
  const b = externalSignal || null;
127
- if (!a && !b) return null;
128
- if (!a) return b;
129
- if (!b) return a;
130
- if (a === b) return a;
127
+ if (!a && !b) return { signal: null, cleanup() {} };
128
+ if (!a) return { signal: b, cleanup() {} };
129
+ if (!b) return { signal: a, cleanup() {} };
130
+ if (a === b) return { signal: a, cleanup() {} };
131
131
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
132
- try { return AbortSignal.any([a, b]); } catch { /* fall through */ }
132
+ try { return { signal: AbortSignal.any([a, b]), cleanup() {} }; } catch { /* fall through */ }
133
133
  }
134
134
  const ctl = new AbortController();
135
135
  const onAbort = (sig) => {
136
136
  if (ctl.signal.aborted) return;
137
137
  try { ctl.abort(sig?.reason); } catch { try { ctl.abort(); } catch {} }
138
138
  };
139
- if (a.aborted) { onAbort(a); return ctl.signal; }
140
- if (b.aborted) { onAbort(b); return ctl.signal; }
141
- try { a.addEventListener('abort', () => onAbort(a), { once: true }); } catch {}
142
- try { b.addEventListener('abort', () => onAbort(b), { once: true }); } catch {}
143
- return ctl.signal;
139
+ if (a.aborted) { onAbort(a); return { signal: ctl.signal, cleanup() {} }; }
140
+ if (b.aborted) { onAbort(b); return { signal: ctl.signal, cleanup() {} }; }
141
+ const onAbortA = () => onAbort(a);
142
+ const onAbortB = () => onAbort(b);
143
+ try { a.addEventListener('abort', onAbortA, { once: true }); } catch {}
144
+ try { b.addEventListener('abort', onAbortB, { once: true }); } catch {}
145
+ return {
146
+ signal: ctl.signal,
147
+ cleanup() {
148
+ try { a.removeEventListener('abort', onAbortA); } catch {}
149
+ try { b.removeEventListener('abort', onAbortB); } catch {}
150
+ },
151
+ };
144
152
  }
145
153
 
146
154
  function _prefixPowerShellUtf8(command) {
@@ -228,7 +236,11 @@ export async function executeBashTool(args, workDir, options = {}) {
228
236
  const userProvidedSession = typeof args.session_id === 'string' && args.session_id.trim().length > 0;
229
237
  const shouldCreate = args.create === true || !userProvidedSession;
230
238
  effectiveArgs = { ...effectiveArgs, create: shouldCreate };
231
- return executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort, sessionId: options?.sessionId });
239
+ try {
240
+ return await executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort.signal, sessionId: options?.sessionId });
241
+ } finally {
242
+ combinedPersistAbort.cleanup();
243
+ }
232
244
  }
233
245
 
234
246
  let command = args.command;
@@ -276,6 +288,7 @@ export async function executeBashTool(args, workDir, options = {}) {
276
288
  }
277
289
 
278
290
  let shellEffects;
291
+ let combinedBashAbort = null;
279
292
  try {
280
293
  shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
281
294
  } catch (err) {
@@ -423,7 +436,7 @@ export async function executeBashTool(args, workDir, options = {}) {
423
436
  let bashAbortSignal = null;
424
437
  try { bashAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
425
438
  catch { bashAbortSignal = null; }
426
- const combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
439
+ combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
427
440
  // Sync path only: chain a trailing cwd probe so the session's final
428
441
  // working directory persists to the next shell call. Async jobs run
429
442
  // detached and are intentionally excluded (they never reach here). The
@@ -456,7 +469,7 @@ export async function executeBashTool(args, workDir, options = {}) {
456
469
  env: spawnEnv,
457
470
  cwd: bashWorkDir,
458
471
  timeoutMs: timeout,
459
- abortSignal: combinedBashAbort,
472
+ abortSignal: combinedBashAbort.signal,
460
473
  autoBackgroundMs,
461
474
  // On a foreground timeout, promote the still-running child to a
462
475
  // tracked background job (unlimited) instead of killing it.
@@ -592,6 +605,7 @@ export async function executeBashTool(args, workDir, options = {}) {
592
605
  return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
593
606
  }
594
607
  finally {
608
+ combinedBashAbort?.cleanup?.();
595
609
  if (shellEffects.mutationMode === 'paths') {
596
610
  invalidateBuiltinResultCache(shellEffects.paths);
597
611
  markCodeGraphDirtyPaths(shellEffects.paths);