mixdog 0.9.67 → 0.9.69

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 (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -746,579 +746,13 @@ export function deleteSession(id, options = {}) {
746
746
  _releaseWriteCommit(commitControl);
747
747
  }
748
748
  }
749
- const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
750
- const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
751
- // Hard wall-clock ceiling for sessions stuck in status='running'. The
752
- // stream-watchdog should abort stalled streams within ~120s, but if it misses
753
- // one (process crash, watchdog not started, provider never returned), this
754
- // backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
755
- const RUNNING_STALL_MS = 10 * 60 * 1000;
756
- // Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
757
- // closes sessions with { tombstone:false } — the runtime detaches but the
758
- // session JSON stays open/resumable and is never lifecycle-closed, so without
759
- // a cap the sessions/ dir grows without bound (observed 782 open files). The
760
- // sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
761
- // newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
762
- // sessions — explicit USER-owned conversations are never auto-pruned (deleting
763
- // a user's history, including the current foreground session which is idle
764
- // during a gated sweep, is unacceptable). A session with a live runtime entry
765
- // (options.isSessionLive) is additionally protected as defense-in-depth.
766
- const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
767
- const RESUMABLE_OPEN_MAX_COUNT = 300;
768
- // Blank scratch sessions (zero user/assistant conversation) are reaped once
769
- // idle this long — see the blank-scratch branch in sweepStaleSessions.
770
- const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
771
749
 
772
-
773
- export function listStoredSessions(options = {}) {
774
- const dir = getStoreDir();
775
- if (!existsSync(dir))
776
- return [];
777
- const files = readdirSync(dir).filter(f => f.endsWith('.json'));
778
- const sessionsById = new Map();
779
- const invalidStorageIds = options._invalidStorageIds instanceof Set
780
- ? options._invalidStorageIds
781
- : new Set();
782
- for (const f of files) {
783
- const session = _storedSessionFromFile(dir, f);
784
- if (session) {
785
- sessionsById.set(session.id, session);
786
- continue;
787
- }
788
- const storageId = f.slice(0, -5);
789
- if (/^[A-Za-z0-9_-]+$/.test(storageId)) invalidStorageIds.add(storageId);
790
- }
791
- const stored = [...sessionsById.values()];
792
- return options.includeLive === true
793
- ? _withUnpersistedSessions(stored, invalidStorageIds)
794
- : stored.sort((a, b) => b.updatedAt - a.updatedAt);
795
- }
796
-
797
- function _withUnpersistedSessions(stored, invalidStorageIds = new Set()) {
798
- const sessionsById = new Map(stored.map((session) => [session.id, session]));
799
- const addIfUnpersisted = (id, session, opts) => {
800
- // A valid on-disk record is authoritative for refresh/resume. In
801
- // particular, a long-lived runtime object must never replace a
802
- // tombstone or changed desktop authorization metadata. Only active
803
- // local writes with no disk record get read-your-writes visibility.
804
- if (sessionsById.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
805
- if (session?.id === id) sessionsById.set(id, _ensureLifecycleFields(session));
806
- };
807
- for (const [id, pending] of _savePending) {
808
- const payload = pending.queued || pending.payload;
809
- addIfUnpersisted(id, payload?.session, payload?.opts);
810
- }
811
- for (const [, pending] of _saveWorkerPending) {
812
- addIfUnpersisted(pending.id, pending.session, pending.opts);
813
- }
814
- for (const [id, pending] of _saveAsyncQueued) {
815
- addIfUnpersisted(id, pending.session, pending.opts);
816
- }
817
- return [...sessionsById.values()].sort((a, b) => b.updatedAt - a.updatedAt);
818
- }
819
-
820
-
821
- // Summary-level twin of _withUnpersistedSessions: overlay queued/in-flight
822
- // saves that have no disk record yet (read-your-writes for brand-new sessions).
823
- function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
824
- const byId = new Map(rows.map((row) => [row.id, row]));
825
- const addIfUnpersisted = (id, session, opts) => {
826
- if (!id || byId.has(id) || invalidStorageIds.has(id) || _isCancelledWrite(opts)) return;
827
- if (session?.id !== id) return;
828
- const row = _sessionSummary(_ensureLifecycleFields(session));
829
- if (row) byId.set(id, row);
830
- };
831
- for (const [id, pending] of _savePending) {
832
- const payload = pending.queued || pending.payload;
833
- addIfUnpersisted(id, payload?.session, payload?.opts);
834
- }
835
- for (const [, pending] of _saveWorkerPending) addIfUnpersisted(pending.id, pending.session, pending.opts);
836
- for (const [id, pending] of _saveAsyncQueued) addIfUnpersisted(id, pending.session, pending.opts);
837
- return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
838
- }
839
-
840
- function rebuildSessionSummaryIndex() {
841
- _ensureSummaryCacheDataDir();
842
- const { rows } = _scanStoredSessionSummaryRows();
843
- const indexedRows = _writeSummaryIndex(rows);
844
- // This process just authored the new cache base. Remember its mtime so the
845
- // next local save cannot mistake the rebuild for a cross-process update
846
- // and replace a newer in-memory row with the just-obsoleted sidecar.
847
- try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
848
- return _setSummaryRowsCache(indexedRows);
849
- }
850
-
851
- export function listStoredSessionSummaries(options = {}) {
852
- _ensureSummaryCacheDataDir();
853
- const rebuildIfMissing = options.rebuildIfMissing !== false;
854
- // This is intentionally the only path that rescans every session JSON:
855
- // callers use it as an on-demand authoritative refresh (including resume
856
- // authorization), so it must not trust either the cache or sidecar.
857
- if (options.refreshFromStorage === true) {
858
- try {
859
- const { rows: persistedRows, invalidStorageIds, changed } = _scanStoredSessionSummaryRows();
860
- const rows = _overlayUnpersistedSummaryRows(persistedRows, invalidStorageIds);
861
- // Unchanged scans skip the sidecar rewrite — refresh is called on
862
- // every sidebar poll/push and must not grind a multi-MB atomic
863
- // write when no session actually changed.
864
- if (changed) {
865
- try { _writeSummaryIndex(persistedRows); } catch { /* sidecar remains best-effort */ }
866
- }
867
- // A direct scan settles deletion state too; retain only active
868
- // optimistic write overlays, never a stale local removal.
869
- _summaryCacheRemovals.clear();
870
- _setSummaryRowsCache(persistedRows);
871
- return rows;
872
- } catch {
873
- // A refresh is an authorization boundary for desktop resume. If
874
- // authoritative storage cannot be enumerated, stale cached/sidecar
875
- // rows must not be treated as proof that a session is available.
876
- return [];
877
- }
878
- }
879
- if (_summaryRowsCache !== null) {
880
- // A local session save has already updated the in-memory cache but its
881
- // non-blocking sidecar merge may still be queued/in flight. Re-reading
882
- // the older sidecar in that window would temporarily erase the new row.
883
- if (_hasUnsettledSummaryOps()) return _cachedSummaryRows().slice();
884
- // Cross-process freshness: another live process (terminal CLI owning a
885
- // session this surface only views) advances messageCount/updatedAt by
886
- // rewriting the summary index FILE — an in-memory cache that never
887
- // looks back at disk serves frozen rows forever (user: the unread dot
888
- // never fired for terminal-owned growth). One stat per call; when the
889
- // index advanced, re-read the cheap index JSON as the new cache base
890
- // (local optimistic overlays stay applied on top).
891
- let diskMtime = 0;
892
- try { diskMtime = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* no index yet */ }
893
- if (diskMtime <= _summaryIndexMtimeSeen) return _cachedSummaryRows().slice();
894
- try {
895
- const raw = JSON.parse(readFileSync(summaryIndexPath(), 'utf-8'));
896
- if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) {
897
- _summaryIndexMtimeSeen = diskMtime;
898
- return _setSummaryRowsCache(_normalizeSummaryIndex(raw).rows).slice();
899
- }
900
- } catch { /* torn concurrent write — keep serving the cache; retry next call */ }
901
- return _cachedSummaryRows().slice();
902
- }
903
-
904
- let indexedRows = [];
905
- let p;
906
- let hasIndex = false;
907
- try {
908
- p = summaryIndexPath();
909
- hasIndex = existsSync(p);
910
- if (hasIndex) {
911
- const raw = JSON.parse(readFileSync(p, 'utf-8'));
912
- hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
913
- if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
914
- if (hasIndex) {
915
- try { _summaryIndexMtimeSeen = statSync(p).mtimeMs || 0; } catch { /* stat only */ }
916
- }
917
- }
918
- } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
919
-
920
- if (!p || !hasIndex) {
921
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache([]); }
922
- catch { return _setSummaryRowsCache(indexedRows); }
923
- }
924
- try {
925
- if (indexedRows.length > 0) return _setSummaryRowsCache(indexedRows);
926
- const dir = getStoreDir();
927
- const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
928
- return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows);
929
- } catch {
930
- try { return rebuildIfMissing ? rebuildSessionSummaryIndex() : _setSummaryRowsCache(indexedRows); }
931
- catch { return _setSummaryRowsCache(indexedRows); }
932
- }
933
- }
934
-
935
- /**
936
- * Raw directory scan — returns every parseable session file without any
937
- * TTL-based inline deletion. Callers (e.g. sweepTombstones) need to own the
938
- * unlink decision and log it themselves.
939
- */
940
- export function getStoredSessionsRaw() {
941
- const dir = getStoreDir();
942
- if (!existsSync(dir)) return [];
943
- const files = readdirSync(dir).filter(f => f.endsWith('.json'));
944
- const sessions = [];
945
- for (const f of files) {
946
- const session = _storedSessionFromFile(dir, f, false);
947
- if (session) sessions.push(session);
948
- }
949
- return sessions;
950
- }
951
-
952
- /**
953
- * Background sweep: delete session files idle longer than ttlMs.
954
- * Returns { cleaned, remaining, details } for logging.
955
- */
956
- export function sweepStaleSessions(ttlMs, options = {}) {
957
- if (ttlMs && typeof ttlMs === 'object') {
958
- options = ttlMs;
959
- ttlMs = options.ttlMs;
960
- }
961
- const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
962
- const sweepIdle = options.sweepIdle !== false;
963
- let terminalReapConfig = null;
964
- try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
965
- const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
966
- const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
967
- // Retention cap for resumable open sessions runs only on the idle sweep
968
- // (never on a tombstone-only pass). isSessionLive protects the current /
969
- // actively-running sessions from being pruned by the retention cap.
970
- const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
971
- const retainOpen = sweepIdle && options.retainOpenSessions !== false;
972
- const _optAge = Number(options.openMaxAgeMs);
973
- const _optCount = Number(options.openMaxCount);
974
- const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
975
- const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
976
- const dir = getStoreDir();
977
- if (!existsSync(dir))
978
- return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
979
- // Reconcile the index-derived candidate set with a direct directory scan:
980
- // the summary index is a best-effort sidecar that can lag far behind disk
981
- // (thousands of on-disk .json files may be absent from a smaller index).
982
- // Any such orphan closed+mature tombstone would otherwise be unreachable by
983
- // this sweep and accumulate forever. Union the index rows with every
984
- // on-disk .json id, deduped by id; synthetic { id } rows are sufficient
985
- // because the loop below re-reads all lifecycle truth from disk. This stays
986
- // sweep-local and does NOT change listStoredSessionSummaries for other
987
- // callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
988
- const indexRows = listStoredSessionSummaries();
989
- const summaries = indexRows;
990
- try {
991
- const seen = new Set();
992
- for (const row of indexRows) { if (row?.id) seen.add(row.id); }
993
- for (const f of readdirSync(dir)) {
994
- if (!f.endsWith('.json')) continue;
995
- const id = f.slice(0, -5);
996
- if (!id || seen.has(id)) continue;
997
- seen.add(id);
998
- summaries.push({ id });
999
- }
1000
- } catch { /* dir scan failure — fall back to index rows only */ }
1001
- const now = Date.now();
1002
- let cleaned = 0;
1003
- let remaining = 0;
1004
- let tombstonesCleaned = 0;
1005
- const details = [];
1006
- const tombstoneDetails = [];
1007
- const tombstoneErrors = [];
1008
- // Retention-cap bookkeeping: collect surviving open (non-tombstone)
1009
- // sessions here, then prune oldest-first after the main loop.
1010
- const openCandidates = [];
1011
- let openPruned = 0;
1012
- const openPrunedDetails = [];
1013
- for (const row of summaries) {
1014
- try {
1015
- if (!row?.id) continue;
1016
- const jsonPath = sessionPath(row.id);
1017
- if (!existsSync(jsonPath)) {
1018
- _queueSessionSummaryRemoval(row.id);
1019
- continue;
1020
- }
1021
- let jsonMtime = 0;
1022
- let heartbeatMtime = 0;
1023
- try { jsonMtime = statSync(jsonPath).mtimeMs || 0; } catch {}
1024
- try {
1025
- const hbPath = join(dir, `${row.id}.hb`);
1026
- if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
1027
- } catch { /* .hb unavailable — fall back to JSON fields */ }
1028
- // Truth source: the summary index is a deferred/best-effort sidecar,
1029
- // so a row can still claim status='idle'/open while the session JSON
1030
- // was already tombstoned. Read the real session JSON BEFORE the
1031
- // freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
1032
- // state — otherwise idle-sweep re-closes an already-closed session via
1033
- // markSessionClosed (which, pre-fix, reset the tombstone age every
1034
- // 5-min cycle → immortality loop).
1035
- let raw = null;
1036
- try { raw = readFileSync(jsonPath, 'utf-8'); }
1037
- catch { /* racing unlink / transient read failure */ }
1038
- let actual = null;
1039
- let diskClosed;
1040
- if (raw == null) {
1041
- diskClosed = (row.closed === true || row.status === 'closed');
1042
- } else {
1043
- // Cheap top-level scan avoids allocating the whole messages array
1044
- // just to read the closed flag for the (common) fresh open
1045
- // session the gate will skip; full-parse only when the scan can't
1046
- // resolve the top-level flag.
1047
- const scan = scanTopLevelLifecycle(raw);
1048
- if (scan && typeof scan.closed === 'boolean') {
1049
- diskClosed = scan.closed;
1050
- } else {
1051
- try { actual = JSON.parse(raw); } catch { actual = null; }
1052
- diskClosed = actual
1053
- ? (actual.closed === true || actual.status === 'closed')
1054
- : (row.closed === true || row.status === 'closed');
1055
- }
1056
- }
1057
- if (diskClosed) {
1058
- // A shared store can be tombstoned by another process while
1059
- // this process still owns an in-flight controller for the same
1060
- // id. Exclude it before unlinking: clearing only the local
1061
- // runtime after deletion is too late because its eventual save
1062
- // would see no tombstone and could resurrect the session.
1063
- if (isSessionLive && isSessionLive(row.id)) {
1064
- remaining++;
1065
- continue;
1066
- }
1067
- // Closed sessions are EXEMPT from the freshness gate: a tombstone
1068
- // whose file/hb mtime keeps getting bumped would otherwise stay
1069
- // perpetually "fresh" and never mature. Maturity is governed ONLY
1070
- // by the ORIGINAL close time (disk updatedAt, not row.updatedAt
1071
- // which a stale row may carry from before the close).
1072
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
1073
- const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
1074
- const age = now - closedAt;
1075
- if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
1076
- try {
1077
- if (deleteSession(row.id, { deferSummaryUpdate: true })) {
1078
- tombstonesCleaned++;
1079
- tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
1080
- continue;
1081
- }
1082
- } catch (err) {
1083
- tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
1084
- remaining++;
1085
- continue;
1086
- }
1087
- }
1088
- // Repair a stale summary row that still claimed the session was
1089
- // open: reflect the real closed state so the next sweep sees the
1090
- // correct closed=true/updatedAt and never re-closes it.
1091
- if (actual && !(row.closed === true || row.status === 'closed')) {
1092
- try { _queueSessionSummaryUpsert(actual); } catch { /* best-effort */ }
1093
- }
1094
- remaining++;
1095
- continue;
1096
- }
1097
- // Parse the open record before its freshness gate: completed agents
1098
- // use their provider's Advanced terminal duration rather than the
1099
- // general sweep cadence. A short provider override must therefore
1100
- // not be hidden behind the default 5-minute gate.
1101
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
1102
- const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
1103
- ? actual.owner : row.owner;
1104
- const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
1105
- const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
1106
- const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
1107
- && AGENT_TERMINAL_STATUSES.has(gateStatus);
1108
- const terminalReapMsForGate = isCompletedAgentForGate
1109
- ? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
1110
- : null;
1111
- if (isCompletedAgentForGate && terminalReapMsForGate == null) {
1112
- remaining++;
1113
- continue;
1114
- }
1115
- // Freshness gate — OPEN sessions only (closed sessions handled and
1116
- // `continue`d above). Recently-touched open sessions are skipped
1117
- // cheaply here.
1118
- const freshnessGateMs = sweepIdle
1119
- ? (terminalReapMsForGate ?? maxAge)
1120
- : (sweepTombstones ? tombstoneMaxAgeMs : 0);
1121
- const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
1122
- if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
1123
- // Fresh agent/legacy sessions survive idle close but still
1124
- // participate in the resumable-open retention cap. The cap
1125
- // performs its own commit-edge liveness veto before deletion.
1126
- if (retainOpen && sweepIdle
1127
- && (!(typeof gateOwner === 'string' && gateOwner.length > 0)
1128
- || isAgentOwner({ owner: gateOwner }))) {
1129
- openCandidates.push({
1130
- id: row.id,
1131
- lastActive: newestKnown,
1132
- heartbeatSnapshotMtime: heartbeatMtime,
1133
- heartbeatFreshMs: terminalReapMsForGate ?? maxAge,
1134
- });
1135
- }
1136
- remaining++;
1137
- continue;
1138
- }
1139
- // Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
1140
- // possibly stale) summary row for every open/idle liveness and
1141
- // ownership decision below — a stale row must not close or prune the
1142
- // wrong session. Full-parse here if the cheap scan skipped it.
1143
- if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
1144
- const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
1145
- ? actual.owner : row.owner;
1146
- const ownerRef = { owner: effOwner };
1147
- const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
1148
- const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
1149
- const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
1150
- const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
1151
- const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
1152
- const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
1153
- // Sweep agent-owned and ownerless (legacy) sessions; skip explicit
1154
- // user sessions before touching heartbeat sidecars. USER-owned
1155
- // conversations are NEVER added to the retention-cap candidate set —
1156
- // the cap must not auto-delete user history (nor the current
1157
- // foreground session, which is idle during a gated sweep). Only the
1158
- // ephemeral agent/ownerless sessions below feed the cap.
1159
- if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
1160
- // Blank-scratch exception to user-session permanence: a session
1161
- // with ZERO user/assistant conversation (engine boot artifact,
1162
- // force-killed window, crashed host) has nothing to preserve.
1163
- // Relaunch storms otherwise pile hundreds of "(blank)" rows
1164
- // that no sweep may touch. Reap once cold; liveness/heartbeat
1165
- // vetoes inside deleteSession still protect an in-flight boot.
1166
- const _msgsArr = Array.isArray(actual?.messages) ? actual.messages : null;
1167
- const _convCount = _msgsArr
1168
- ? _msgsArr.filter((m) => m && (m.role === 'user' || m.role === 'assistant')).length
1169
- : (Number(row.messageCount) || 0);
1170
- const _blankLastActive = Math.max(effUpdatedAt, effLastHb, effCreatedAt, heartbeatMtime || 0);
1171
- if (sweepIdle && _convCount === 0
1172
- && now - _blankLastActive > BLANK_SCRATCH_MAX_AGE_MS
1173
- && !(isSessionLive && isSessionLive(row.id))) {
1174
- try {
1175
- if (deleteSession(row.id, {
1176
- deferSummaryUpdate: true,
1177
- isSessionLive,
1178
- heartbeatSnapshotMtime: heartbeatMtime,
1179
- heartbeatFreshMs: BLANK_SCRATCH_MAX_AGE_MS,
1180
- })) {
1181
- openPruned++;
1182
- openPrunedDetails.push({ id: row.id, ageSeconds: Math.floor((now - _blankLastActive) / 1000) });
1183
- continue;
1184
- }
1185
- } catch { /* keep the row on failure */ }
1186
- }
1187
- remaining++;
1188
- continue;
1189
- }
1190
- if (!sweepIdle) {
1191
- remaining++;
1192
- continue;
1193
- }
1194
- // The manager may sweep while unrelated sessions are active. Protect
1195
- // this specific locally-current/in-flight session regardless of stale
1196
- // on-disk timestamps; its controller/heartbeat owner decides when it
1197
- // is safe to become an idle-sweep candidate.
1198
- if (isSessionLive && isSessionLive(row.id)) {
1199
- remaining++;
1200
- continue;
1201
- }
1202
- // Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
1203
- // serialising the full JSON, so it reflects true liveness more
1204
- // accurately than the JSON timestamp fields.
1205
- let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
1206
- if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
1207
- // Running sessions are normally reaped by the stream-watchdog
1208
- // within ~120s. Skip them here unless they've been silent past
1209
- // RUNNING_STALL_MS, at which point they are treated as zombies.
1210
- if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
1211
- remaining++;
1212
- continue;
1213
- }
1214
- const isCompletedAgent = isAgentOwner(ownerRef)
1215
- && AGENT_TERMINAL_STATUSES.has(effStatus);
1216
- const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
1217
- const sessionMaxAge = terminalReapMs ?? maxAge;
1218
- if (now - lastActive > sessionMaxAge) {
1219
- // Close is destructive and the earlier heartbeat stat can race a
1220
- // different process publishing fresh liveness. Re-check both
1221
- // local runtime ownership and the sidecar at the commit edge.
1222
- if (isSessionLive && isSessionLive(row.id)) {
1223
- remaining++;
1224
- continue;
1225
- }
1226
- let preCloseHeartbeatMtime = 0;
1227
- try {
1228
- const hbPath = join(dir, `${row.id}.hb`);
1229
- if (existsSync(hbPath)) preCloseHeartbeatMtime = statSync(hbPath).mtimeMs || 0;
1230
- } catch { /* sidecar unavailable — retain scan-time gates */ }
1231
- if (preCloseHeartbeatMtime > 0 && now - preCloseHeartbeatMtime <= sessionMaxAge) {
1232
- remaining++;
1233
- continue;
1234
- }
1235
- let closeResult = null;
1236
- try {
1237
- closeResult = markSessionClosed(row.id, 'idle-sweep', {
1238
- isSessionLive,
1239
- heartbeatSnapshotMtime: heartbeatMtime,
1240
- heartbeatFreshMs: sessionMaxAge,
1241
- });
1242
- }
1243
- catch (err) {
1244
- process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
1245
- continue;
1246
- }
1247
- if (closeResult == null) {
1248
- remaining++;
1249
- continue;
1250
- }
1251
- cleaned++;
1252
- details.push({
1253
- id: row.id,
1254
- owner: effOwner || 'unknown',
1255
- idleMinutes: Math.round((now - lastActive) / 60000),
1256
- bashSessionId: effBashId,
1257
- });
1258
- } else {
1259
- if (retainOpen) openCandidates.push({
1260
- id: row.id,
1261
- lastActive,
1262
- heartbeatSnapshotMtime: heartbeatMtime,
1263
- heartbeatFreshMs: sessionMaxAge,
1264
- });
1265
- remaining++;
1266
- }
1267
- }
1268
- catch { /* skip corrupt */ }
1269
- }
1270
- // ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
1271
- // Newest-first: keep the most recent openMaxCount, prune anything older than
1272
- // openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
1273
- // are never pruned but still occupy a kept slot.
1274
- if (retainOpen && openCandidates.length > 0) {
1275
- openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
1276
- let kept = 0;
1277
- for (const c of openCandidates) {
1278
- if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
1279
- const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
1280
- const overCount = kept >= openMaxCount;
1281
- if (!tooOld && !overCount) { kept++; continue; }
1282
- try {
1283
- if (deleteSession(c.id, {
1284
- deferSummaryUpdate: true,
1285
- isSessionLive,
1286
- heartbeatSnapshotMtime: c.heartbeatSnapshotMtime,
1287
- heartbeatFreshMs: c.heartbeatFreshMs,
1288
- })) {
1289
- openPruned++;
1290
- openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
1291
- if (remaining > 0) remaining--;
1292
- } else {
1293
- kept++;
1294
- }
1295
- } catch { kept++; }
1296
- }
1297
- }
1298
- // Orphan .hb/.own reap: a heartbeat/presence sidecar whose .json no longer
1299
- // exists is dead weight once it is also stale (older than maxAge) — the
1300
- // session JSON was swept/closed but the sidecar lingered (crashed owner or
1301
- // pre-fix orphan). The staleness gate avoids nuking the sidecar of a
1302
- // session mid-create whose .json write has not landed yet.
1303
- try {
1304
- for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
1305
- if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
1306
- let hbMtime = 0;
1307
- try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
1308
- if (now - hbMtime > maxAge) {
1309
- try { unlinkSync(join(dir, h)); cleaned++; } catch { /* ignore */ }
1310
- }
1311
- }
1312
- } catch { /* dir scan failure — non-fatal */ }
1313
- // Batched summary-index prune for deferred tombstone deletions: one
1314
- // read-modify-write for the whole sweep instead of one per deleted id
1315
- // (the index is multi-MB at scale; per-id rewrites made large sweeps
1316
- // quadratic and stalled boot for seconds).
1317
- if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
1318
- try {
1319
- const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
1320
- _queueSummaryIndexPrune(deletedIds);
1321
- } catch { /* summary index is best-effort */ }
1322
- }
1323
- return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
1324
- }
750
+ // Listing / summaries / stale sweeping live in store/listing.mjs; re-exported
751
+ // here so importers keep one session-store entry point.
752
+ export {
753
+ listStoredSessions,
754
+ listStoredSessionSummaries,
755
+ getStoredSessionsRaw,
756
+ sweepStaleSessions,
757
+ } from './store/listing.mjs';
758
+ export { _savePending };
@@ -12,6 +12,44 @@ const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
12
12
  const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
13
13
  const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
14
14
  const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
15
+ // clearOffloadSession only runs on a CLEAN session close, so every killed
16
+ // process leaves its tool-results/<session>/ sidecars behind forever. Sweep
17
+ // directories whose NEWEST file is past the TTL (a session still writing must
18
+ // survive), at most once per interval per process.
19
+ const OFFLOAD_SESSION_STALE_MS = 24 * 60 * 60 * 1000;
20
+ const OFFLOAD_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
21
+ let _offloadSweptAt = 0;
22
+
23
+ async function sweepStaleOffloadDirs() {
24
+ const root = join(getPluginData(), 'tool-results');
25
+ let names;
26
+ try { names = await readdir(root); } catch { return; }
27
+ const cutoff = Date.now() - OFFLOAD_SESSION_STALE_MS;
28
+ await Promise.all(names.map(async (name) => {
29
+ const dir = join(root, name);
30
+ try {
31
+ const dirStat = await stat(dir);
32
+ if (!dirStat.isDirectory()) return;
33
+ const entries = await readdir(dir);
34
+ let newest = dirStat.mtimeMs;
35
+ await Promise.all(entries.map(async (entry) => {
36
+ try {
37
+ const fileStat = await stat(join(dir, entry));
38
+ if (fileStat.mtimeMs > newest) newest = fileStat.mtimeMs;
39
+ } catch { /* vanished mid-sweep */ }
40
+ }));
41
+ if (newest >= cutoff) return;
42
+ await Promise.all(entries.map((entry) => unlink(join(dir, entry)).catch(() => {})));
43
+ await rmdir(dir).catch(() => { /* raced a live writer */ });
44
+ } catch { /* best-effort */ }
45
+ }));
46
+ }
47
+
48
+ function maybeSweepStaleOffloadDirs() {
49
+ if (Date.now() - _offloadSweptAt < OFFLOAD_SWEEP_INTERVAL_MS) return;
50
+ _offloadSweptAt = Date.now();
51
+ void sweepStaleOffloadDirs();
52
+ }
15
53
 
16
54
  // Per-tool persistence limits mirror reference per-tool maxResultSizeChars
17
55
  // rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
@@ -58,6 +96,9 @@ function safeSessionSegment(sessionId) {
58
96
 
59
97
  function ensureToolResultsDir(sessionId) {
60
98
  const dir = join(getPluginData(), 'tool-results', safeSessionSegment(sessionId));
99
+ // Fire-and-forget GC on the first offload of each interval — never awaited,
100
+ // so an offload write is not delayed by the reclaim walk.
101
+ maybeSweepStaleOffloadDirs();
61
102
  // R4 data-at-rest: offloaded tool output may contain secrets / file
62
103
  // contents; clamp dir to owner-only on POSIX (advisory on Windows).
63
104
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });