sidekick-shared 0.27.6 → 0.28.0

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 (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,939 @@
1
+ import * as fs from 'node:fs';
2
+ import { createProviderSessionAdapterV1, getObservedActivityReason, } from './types/observedSessionV1';
3
+ import { refreshSessionActivityState } from './parsers/sessionActivityDetector';
4
+ import { fileFingerprintParts, fingerprintString } from './sessionFingerprint';
5
+ export { fileFingerprint, fileFingerprintParts } from './sessionFingerprint';
6
+ const DEFAULT_MAX_CONCURRENT_READS = 4;
7
+ const MAX_CONCURRENT_READS = 16;
8
+ const DEFAULT_DEBOUNCE_MS = 100;
9
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
10
+ const DEFAULT_MIN_RECONCILE_GAP_MS = 2_000;
11
+ const DEFAULT_MAX_CACHE_ENTRIES = 20_000;
12
+ const DEFAULT_MAX_CACHE_BYTES = 128 * 1024 * 1024;
13
+ const FALLBACK_VALUE_BYTES = 1_024;
14
+ /** A full discovery may recur once it has been idle this many times its own duration. */
15
+ const GAP_COST_MULTIPLIER = 4;
16
+ /**
17
+ * Fail-soft observed-session collection with fingerprint-keyed parse caching,
18
+ * cooperative reads, and optional provider-level subscriptions.
19
+ */
20
+ export class ObservedSessionCollector {
21
+ options;
22
+ failures = new Map();
23
+ cache;
24
+ subscriptions = new Set();
25
+ /** Latest complete reference set per provider, kept current by scoped reconciles. */
26
+ lastDiscovery = new Map();
27
+ /** One full discovery per provider at a time; concurrent callers share it. */
28
+ inflightDiscovery = new Map();
29
+ fullPassState = new Map();
30
+ /** Scoped results newer than an in-flight walk, so the walk cannot undo them. */
31
+ scopedReferences = new Map();
32
+ generation = 0;
33
+ passId = 0;
34
+ initialBackoffMs;
35
+ maxBackoffMs;
36
+ clock;
37
+ maxConcurrentReads;
38
+ minReconcileGapMs;
39
+ yieldBetweenReads;
40
+ constructor(options) {
41
+ this.options = options;
42
+ this.initialBackoffMs = Math.max(1, options.initialBackoffMs ?? 30_000);
43
+ this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 5 * 60_000);
44
+ this.clock = options.clock ?? { now: () => Date.now() };
45
+ this.maxConcurrentReads = Math.min(MAX_CONCURRENT_READS, Math.max(1, Math.floor(options.maxConcurrentReads ?? DEFAULT_MAX_CONCURRENT_READS)));
46
+ this.minReconcileGapMs = Math.max(0, options.minReconcileGapMs ?? DEFAULT_MIN_RECONCILE_GAP_MS);
47
+ const estimate = options.approximateValueBytes ?? defaultValueBytes;
48
+ this.cache = new BoundedCache(Math.max(1, Math.floor(options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES)), Math.max(1, Math.floor(options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES)), (value) => {
49
+ try {
50
+ const bytes = estimate(value);
51
+ return Number.isFinite(bytes) && bytes >= 0 ? bytes : FALLBACK_VALUE_BYTES;
52
+ }
53
+ catch {
54
+ return FALLBACK_VALUE_BYTES;
55
+ }
56
+ });
57
+ this.yieldBetweenReads = async () => {
58
+ if (options.yieldBetweenReads) {
59
+ await options.yieldBetweenReads();
60
+ return;
61
+ }
62
+ await new Promise((resolve) => setImmediate(resolve));
63
+ };
64
+ }
65
+ async collect(options = {}) {
66
+ this.passId++;
67
+ const nested = await Promise.all(this.options.sources.map((source) => this.collectSource(source, options)));
68
+ return nested.flat();
69
+ }
70
+ /** Number of parsed sessions currently cached. */
71
+ get cacheSize() {
72
+ return this.cache.size;
73
+ }
74
+ /**
75
+ * Subscribe to debounced provider changes. The listener receives fingerprints
76
+ * only; call collect() to consume changed sessions through the parse cache.
77
+ *
78
+ * A signal that names a file is reconciled with one stat. Signals without a
79
+ * filename, catch-up polls, and the initial pass run a full discovery, at
80
+ * most once per gap per provider; bursts inside the debounce window join a
81
+ * single trailing pass.
82
+ */
83
+ subscribe(listener, options = {}) {
84
+ const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
85
+ const pollIntervalMs = Math.max(0, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
86
+ const gapCeilingMs = Math.max(0, options.minReconcileGapMs ?? this.minReconcileGapMs);
87
+ const known = new Map();
88
+ for (const item of options.knownFingerprints ?? []) {
89
+ known.set(this.key(item.providerId, item.sessionId), {
90
+ fingerprint: item.fingerprint,
91
+ parts: item.fingerprintParts ?? null,
92
+ });
93
+ }
94
+ for (const [key, entry] of this.cache) {
95
+ known.set(key, { fingerprint: entry.fingerprint, parts: entry.fingerprintParts });
96
+ }
97
+ let disposed = false;
98
+ let timer = null;
99
+ let timerFireAt = 0;
100
+ let running = false;
101
+ let pendingScoped = new Map();
102
+ let pendingFull = new Map();
103
+ const sourceDisposables = [];
104
+ const gapRemainingMs = (source, trigger) => {
105
+ if (trigger === 'initial')
106
+ return 0;
107
+ const state = this.fullPassState.get(source.providerId);
108
+ if (!state)
109
+ return 0;
110
+ return Math.max(0, state.endedAt + this.effectiveGapMs(source.providerId, gapCeilingMs) - Date.now());
111
+ };
112
+ const emit = (batch) => {
113
+ if (batch.changes.length === 0)
114
+ return;
115
+ try {
116
+ listener(batch);
117
+ }
118
+ catch {
119
+ // Subscriber failures cannot stop reconciliation.
120
+ }
121
+ };
122
+ // Arms the trailing timer; a later request can only pull it earlier,
123
+ // never push it out, so a busy live session cannot starve the pass.
124
+ const arm = (delayMs) => {
125
+ if (disposed)
126
+ return;
127
+ const fireAt = Date.now() + delayMs;
128
+ if (timer && timerFireAt <= fireAt)
129
+ return;
130
+ if (timer)
131
+ clearTimeout(timer);
132
+ timerFireAt = fireAt;
133
+ timer = setTimeout(() => {
134
+ timer = null;
135
+ void run();
136
+ }, delayMs);
137
+ timer.unref?.();
138
+ };
139
+ const requestFull = (source, trigger) => {
140
+ const existing = pendingFull.get(source);
141
+ if (!existing || existing === 'event')
142
+ pendingFull.set(source, trigger);
143
+ };
144
+ const onSignal = (source, signal) => {
145
+ if (disposed)
146
+ return;
147
+ const parsed = typeof signal === 'object' && signal !== null ? signal : undefined;
148
+ if (parsed &&
149
+ typeof parsed.filename === 'string' &&
150
+ parsed.filename.length > 0 &&
151
+ source.resolveReference) {
152
+ pendingScoped.set(`${source.providerId}\0${parsed.root ?? ''}\0${parsed.filename}`, {
153
+ source,
154
+ signal: parsed,
155
+ });
156
+ arm(debounceMs);
157
+ return;
158
+ }
159
+ const trigger = parsed?.trigger === 'poll' ? 'poll' : 'event';
160
+ requestFull(source, trigger);
161
+ arm(Math.max(debounceMs, gapRemainingMs(source, trigger)));
162
+ };
163
+ const run = async () => {
164
+ if (disposed || running)
165
+ return;
166
+ running = true;
167
+ // Swap before the first await so signals landing mid-run are kept.
168
+ const scoped = pendingScoped;
169
+ pendingScoped = new Map();
170
+ const full = pendingFull;
171
+ pendingFull = new Map();
172
+ try {
173
+ const scopedChanges = new Map();
174
+ for (const { source, signal } of scoped.values()) {
175
+ if (disposed)
176
+ return;
177
+ const result = await this.reconcileSignal(source, signal, known, gapCeilingMs);
178
+ if (disposed)
179
+ return;
180
+ if (result === 'unknown') {
181
+ requestFull(source, 'event');
182
+ continue;
183
+ }
184
+ if (!result)
185
+ continue;
186
+ const list = scopedChanges.get(source) ?? [];
187
+ list.push(result);
188
+ scopedChanges.set(source, list);
189
+ }
190
+ for (const [source, changes] of scopedChanges) {
191
+ emit({
192
+ providerId: source.providerId,
193
+ changes,
194
+ observedAt: new Date(this.clock.now()).toISOString(),
195
+ });
196
+ }
197
+ for (const [source, trigger] of full) {
198
+ if (disposed)
199
+ return;
200
+ if (gapRemainingMs(source, trigger) > 0) {
201
+ requestFull(source, trigger);
202
+ continue;
203
+ }
204
+ const batch = await this.reconcileSource(source, known, trigger, gapCeilingMs);
205
+ // Re-check after the await: disposal during the reconcile must
206
+ // not deliver the pre-disposal batch to the listener.
207
+ if (disposed)
208
+ return;
209
+ if (batch)
210
+ emit(batch);
211
+ }
212
+ }
213
+ finally {
214
+ running = false;
215
+ if (!disposed && (pendingScoped.size > 0 || pendingFull.size > 0)) {
216
+ let delayMs = pendingScoped.size > 0 ? debounceMs : Infinity;
217
+ for (const [source, trigger] of pendingFull) {
218
+ delayMs = Math.min(delayMs, Math.max(debounceMs, gapRemainingMs(source, trigger)));
219
+ }
220
+ arm(delayMs);
221
+ }
222
+ }
223
+ };
224
+ let needsCollectorPoll = false;
225
+ for (const source of this.options.sources) {
226
+ if (source.subscribe) {
227
+ try {
228
+ sourceDisposables.push(source.subscribe((signal) => onSignal(source, signal), { pollIntervalMs }));
229
+ }
230
+ catch {
231
+ // The collector-level poll below remains the correctness fallback.
232
+ needsCollectorPoll = true;
233
+ }
234
+ }
235
+ else {
236
+ needsCollectorPoll = true;
237
+ }
238
+ }
239
+ let pollTimer = null;
240
+ if (needsCollectorPoll && pollIntervalMs > 0) {
241
+ pollTimer = setInterval(() => {
242
+ for (const source of this.options.sources)
243
+ onSignal(source, { trigger: 'poll' });
244
+ }, pollIntervalMs);
245
+ pollTimer.unref?.();
246
+ }
247
+ for (const source of this.options.sources)
248
+ requestFull(source, 'initial');
249
+ arm(debounceMs);
250
+ const disposable = {
251
+ dispose: () => {
252
+ if (disposed)
253
+ return;
254
+ disposed = true;
255
+ if (timer)
256
+ clearTimeout(timer);
257
+ if (pollTimer)
258
+ clearInterval(pollTimer);
259
+ for (const item of sourceDisposables) {
260
+ try {
261
+ item.dispose();
262
+ }
263
+ catch {
264
+ // Disposal is best effort.
265
+ }
266
+ }
267
+ this.subscriptions.delete(disposable);
268
+ },
269
+ };
270
+ this.subscriptions.add(disposable);
271
+ return disposable;
272
+ }
273
+ reset() {
274
+ this.failures.clear();
275
+ this.cache.clear();
276
+ this.lastDiscovery.clear();
277
+ this.scopedReferences.clear();
278
+ }
279
+ dispose() {
280
+ for (const subscription of [...this.subscriptions])
281
+ subscription.dispose();
282
+ for (const source of this.options.sources) {
283
+ try {
284
+ source.dispose?.();
285
+ }
286
+ catch {
287
+ // Disposal is best-effort and isolated per source.
288
+ }
289
+ }
290
+ this.reset();
291
+ }
292
+ async collectSource(source, options) {
293
+ const discoveryKey = `${source.providerId}\0$discovery`;
294
+ const discoveryFailure = this.failures.get(discoveryKey);
295
+ if (discoveryFailure && this.clock.now() < discoveryFailure.retryAt)
296
+ return [];
297
+ const partial = options.limit !== undefined || options.since !== undefined;
298
+ let references;
299
+ // A reconcile pass within the gap already produced the references; a
300
+ // collect() issued from the listener must not walk the provider again.
301
+ const snapshot = !partial && this.subscriptions.size > 0
302
+ ? this.lastDiscovery.get(source.providerId)
303
+ : undefined;
304
+ if (snapshot && Date.now() < snapshot.reuseUntil) {
305
+ references = [...snapshot.references.values()];
306
+ }
307
+ else {
308
+ try {
309
+ references = await this.runDiscovery(source, 'collect', options);
310
+ this.recover(discoveryKey, 'provider-recovered', source.providerId);
311
+ }
312
+ catch (error) {
313
+ this.fail(discoveryKey, 'provider-discovery-failed', source.providerId, undefined, null, error);
314
+ return [];
315
+ }
316
+ }
317
+ if (!partial) {
318
+ const activeKeys = new Set(references.map((ref) => this.key(source.providerId, ref.sessionId)));
319
+ this.evictMissing(source.providerId, activeKeys);
320
+ }
321
+ const rows = await this.mapWithConcurrency(references, async (reference) => {
322
+ const result = await this.collectSession(source, reference);
323
+ await this.yieldBetweenReads();
324
+ return result;
325
+ });
326
+ return rows.filter((row) => row !== null);
327
+ }
328
+ async collectSession(source, reference) {
329
+ const key = this.key(source.providerId, reference.sessionId);
330
+ const fingerprint = await this.fingerprint(source, reference);
331
+ const priorFailure = this.failures.get(key);
332
+ if (priorFailure &&
333
+ priorFailure.fingerprint === fingerprint.value &&
334
+ this.clock.now() < priorFailure.retryAt) {
335
+ return null;
336
+ }
337
+ const observedAt = new Date(this.clock.now()).toISOString();
338
+ const cached = fingerprint.value === null ? undefined : this.cache.get(key, this.passId);
339
+ if (cached && cached.fingerprint === fingerprint.value) {
340
+ let value = cached.value;
341
+ try {
342
+ if (source.refreshCached) {
343
+ value = await source.refreshCached(reference, cached.value, observedAt);
344
+ }
345
+ }
346
+ catch {
347
+ value = cached.value;
348
+ }
349
+ cached.value = value;
350
+ cached.fingerprintParts = fingerprint.parts;
351
+ const observation = this.makeObservation(source, reference, value, fingerprint, true, observedAt, cached.contentObservedAt);
352
+ this.emitObservation(observation);
353
+ return observation;
354
+ }
355
+ let value;
356
+ try {
357
+ value = await source.read(reference);
358
+ }
359
+ catch (error) {
360
+ this.fail(key, 'session-read-failed', source.providerId, reference.sessionId, fingerprint.value, error);
361
+ return null;
362
+ }
363
+ this.recover(key, 'session-recovered', source.providerId, reference.sessionId);
364
+ const contentObservedAt = contentTimestamp(value) ?? observedAt;
365
+ if (fingerprint.value !== null) {
366
+ this.cache.set(key, {
367
+ value,
368
+ fingerprint: fingerprint.value,
369
+ fingerprintParts: fingerprint.parts,
370
+ contentObservedAt,
371
+ }, this.passId);
372
+ }
373
+ const observation = this.makeObservation(source, reference, value, fingerprint, false, observedAt, contentObservedAt);
374
+ this.emitObservation(observation);
375
+ return observation;
376
+ }
377
+ makeObservation(source, reference, value, fingerprint, cacheHit, observedAt, contentObservedAt) {
378
+ return {
379
+ providerId: source.providerId,
380
+ sessionId: reference.sessionId,
381
+ value,
382
+ fingerprint: fingerprint.value,
383
+ fingerprintParts: fingerprint.parts,
384
+ cacheHit,
385
+ observedAt,
386
+ contentObservedAt,
387
+ };
388
+ }
389
+ emitObservation(observation) {
390
+ try {
391
+ this.options.onObservation?.(observation);
392
+ }
393
+ catch {
394
+ // Host callbacks cannot turn a healthy provider read into a collection failure.
395
+ }
396
+ }
397
+ /**
398
+ * Runs `discover()` once per provider at a time. Unbounded discoveries are
399
+ * shared with concurrent callers and update the gap bookkeeping; every
400
+ * discovery emits one payload-free cost diagnostic.
401
+ */
402
+ runDiscovery(source, trigger, options = {}) {
403
+ return this.startDiscovery(source, trigger, options).promise;
404
+ }
405
+ /**
406
+ * Starts (or joins) a discovery and reports the generation it started at,
407
+ * so a reconcile that joins a walk begun earlier by `collect()` compares
408
+ * scoped results against the walk's start rather than its own entry.
409
+ */
410
+ startDiscovery(source, trigger, options = {}) {
411
+ const providerId = source.providerId;
412
+ const partial = options.limit !== undefined || options.since !== undefined;
413
+ if (!partial) {
414
+ const inflight = this.inflightDiscovery.get(providerId);
415
+ if (inflight)
416
+ return inflight;
417
+ }
418
+ const generation = this.generation;
419
+ const stats = {};
420
+ const startedAt = Date.now();
421
+ const promise = (async () => {
422
+ const references = await source.discover({
423
+ limit: options.limit,
424
+ since: options.since,
425
+ stats,
426
+ });
427
+ const endedAt = Date.now();
428
+ const durationMs = endedAt - startedAt;
429
+ if (!partial)
430
+ this.fullPassState.set(providerId, { endedAt, durationMs });
431
+ this.emitDiagnostic(compact({
432
+ kind: 'provider-discovery-completed',
433
+ severity: 'info',
434
+ phase: 'discover',
435
+ providerId,
436
+ trigger,
437
+ durationMs,
438
+ referenceCount: references.length,
439
+ directoriesListed: stats.directoriesListed,
440
+ directoriesStatted: stats.directoriesStatted,
441
+ filesStatted: stats.filesStatted,
442
+ partial: partial ? true : undefined,
443
+ }));
444
+ return references;
445
+ })();
446
+ const handle = { promise, generation };
447
+ if (!partial) {
448
+ this.inflightDiscovery.set(providerId, handle);
449
+ const settle = () => {
450
+ if (this.inflightDiscovery.get(providerId) === handle) {
451
+ this.inflightDiscovery.delete(providerId);
452
+ }
453
+ };
454
+ promise.then(settle, settle);
455
+ }
456
+ return handle;
457
+ }
458
+ effectiveGapMs(providerId, ceilingMs) {
459
+ const state = this.fullPassState.get(providerId);
460
+ if (!state)
461
+ return 0;
462
+ return Math.min(ceilingMs, state.durationMs * GAP_COST_MULTIPLIER);
463
+ }
464
+ /** Reconcile one watch signal against `known` with at most one stat. */
465
+ async reconcileSignal(source, signal, known, gapCeilingMs) {
466
+ if (!source.resolveReference)
467
+ return 'unknown';
468
+ let resolution;
469
+ try {
470
+ resolution = await source.resolveReference(signal);
471
+ }
472
+ catch {
473
+ return 'unknown';
474
+ }
475
+ if (!resolution || typeof resolution !== 'object')
476
+ return 'unknown';
477
+ const providerId = source.providerId;
478
+ switch (resolution.status) {
479
+ case 'ignored':
480
+ return null;
481
+ case 'missing': {
482
+ const key = this.key(providerId, resolution.sessionId);
483
+ this.scopedReferences.set(key, { reference: null, generation: ++this.generation });
484
+ this.lastDiscovery.get(providerId)?.references.delete(resolution.sessionId);
485
+ const previous = known.get(key);
486
+ if (!previous)
487
+ return null;
488
+ known.delete(key);
489
+ return {
490
+ type: 'removed',
491
+ reference: { sessionId: resolution.sessionId },
492
+ previousFingerprint: previous.fingerprint,
493
+ previousFingerprintParts: previous.parts,
494
+ fingerprint: null,
495
+ fingerprintParts: null,
496
+ };
497
+ }
498
+ case 'present': {
499
+ const reference = resolution.reference;
500
+ const key = this.key(providerId, reference.sessionId);
501
+ const current = await this.fingerprint(source, reference);
502
+ if (current.value === null)
503
+ return null;
504
+ this.scopedReferences.set(key, { reference, generation: ++this.generation });
505
+ const snapshot = this.lastDiscovery.get(providerId);
506
+ if (snapshot) {
507
+ snapshot.references.set(reference.sessionId, reference);
508
+ snapshot.reuseUntil = Date.now() + this.effectiveGapMs(providerId, gapCeilingMs);
509
+ }
510
+ const previous = known.get(key);
511
+ known.set(key, { fingerprint: current.value, parts: current.parts });
512
+ if (previous && previous.fingerprint === current.value)
513
+ return null;
514
+ return {
515
+ type: previous ? 'changed' : 'added',
516
+ reference,
517
+ previousFingerprint: previous?.fingerprint ?? null,
518
+ previousFingerprintParts: previous?.parts ?? null,
519
+ fingerprint: current.value,
520
+ fingerprintParts: current.parts,
521
+ };
522
+ }
523
+ default:
524
+ return 'unknown';
525
+ }
526
+ }
527
+ async reconcileSource(source, known, trigger, gapCeilingMs) {
528
+ const providerId = source.providerId;
529
+ const prefix = `${providerId}\0`;
530
+ // The walk may have been started earlier by a concurrent collect(); its
531
+ // start generation, not this pass's entry, decides which scoped results
532
+ // are newer than the references it returns.
533
+ let startGeneration;
534
+ let references;
535
+ try {
536
+ const discovery = this.startDiscovery(source, trigger);
537
+ startGeneration = discovery.generation;
538
+ references = await discovery.promise;
539
+ }
540
+ catch {
541
+ return null;
542
+ }
543
+ const changes = [];
544
+ const seen = new Set();
545
+ const snapshotReferences = new Map();
546
+ for (const discovered of references) {
547
+ const key = this.key(providerId, discovered.sessionId);
548
+ seen.add(key);
549
+ // A scoped result that landed while the walk was running is newer than
550
+ // the walk's copy; use it so the walk cannot flip the fingerprint back.
551
+ const scoped = this.scopedReferences.get(key);
552
+ const newer = scoped !== undefined && scoped.generation > startGeneration;
553
+ if (newer && scoped.reference === null)
554
+ continue;
555
+ const reference = newer && scoped.reference ? scoped.reference : discovered;
556
+ const current = await this.fingerprint(source, reference);
557
+ if (current.value === null)
558
+ continue;
559
+ snapshotReferences.set(reference.sessionId, reference);
560
+ const previous = known.get(key);
561
+ if (!previous || previous.fingerprint !== current.value) {
562
+ changes.push({
563
+ type: previous ? 'changed' : 'added',
564
+ reference,
565
+ previousFingerprint: previous?.fingerprint ?? null,
566
+ previousFingerprintParts: previous?.parts ?? null,
567
+ fingerprint: current.value,
568
+ fingerprintParts: current.parts,
569
+ });
570
+ }
571
+ known.set(key, { fingerprint: current.value, parts: current.parts });
572
+ }
573
+ for (const [key, previous] of [...known]) {
574
+ if (!key.startsWith(prefix) || seen.has(key))
575
+ continue;
576
+ const scoped = this.scopedReferences.get(key);
577
+ if (scoped && scoped.generation > startGeneration && scoped.reference) {
578
+ // Created after the walk began; the scoped pass already reported it.
579
+ snapshotReferences.set(scoped.reference.sessionId, scoped.reference);
580
+ continue;
581
+ }
582
+ const sessionId = key.slice(prefix.length);
583
+ changes.push({
584
+ type: 'removed',
585
+ reference: { sessionId },
586
+ previousFingerprint: previous.fingerprint,
587
+ previousFingerprintParts: previous.parts,
588
+ fingerprint: null,
589
+ fingerprintParts: null,
590
+ });
591
+ known.delete(key);
592
+ }
593
+ for (const [key, entry] of this.scopedReferences) {
594
+ if (key.startsWith(prefix) && entry.generation <= startGeneration) {
595
+ this.scopedReferences.delete(key);
596
+ }
597
+ }
598
+ this.lastDiscovery.set(providerId, {
599
+ references: snapshotReferences,
600
+ reuseUntil: Date.now() + this.effectiveGapMs(providerId, gapCeilingMs),
601
+ });
602
+ return {
603
+ providerId,
604
+ changes,
605
+ observedAt: new Date(this.clock.now()).toISOString(),
606
+ };
607
+ }
608
+ async fingerprint(source, reference) {
609
+ let value = reference.fingerprintHint ?? null;
610
+ try {
611
+ if (this.options.fingerprint)
612
+ value = await this.options.fingerprint(source, reference);
613
+ }
614
+ catch {
615
+ // Retain the reference hint.
616
+ }
617
+ const parts = reference.fingerprintParts ?? parseFingerprintParts(value);
618
+ return { value, parts };
619
+ }
620
+ evictMissing(providerId, activeKeys) {
621
+ for (const key of this.failures.keys()) {
622
+ if (key.startsWith(`${providerId}\0`) &&
623
+ !key.endsWith('$discovery') &&
624
+ !activeKeys.has(key)) {
625
+ this.failures.delete(key);
626
+ }
627
+ }
628
+ for (const key of [...this.cache.keys()]) {
629
+ if (key.startsWith(`${providerId}\0`) && !activeKeys.has(key))
630
+ this.cache.delete(key);
631
+ }
632
+ }
633
+ async mapWithConcurrency(values, mapper) {
634
+ const results = new Array(values.length);
635
+ let cursor = 0;
636
+ const workers = Array.from({ length: Math.min(this.maxConcurrentReads, values.length) }, async () => {
637
+ while (true) {
638
+ const index = cursor++;
639
+ if (index >= values.length)
640
+ return;
641
+ results[index] = await mapper(values[index]);
642
+ }
643
+ });
644
+ await Promise.all(workers);
645
+ return results;
646
+ }
647
+ fail(key, kind, providerId, sessionId, fingerprint, error) {
648
+ const previous = this.failures.get(key);
649
+ const signature = failureSignature(error);
650
+ const fingerprintChanged = previous?.fingerprint !== fingerprint;
651
+ const attempt = previous && !fingerprintChanged ? previous.attempt + 1 : 1;
652
+ const delay = Math.min(this.maxBackoffMs, this.initialBackoffMs * 2 ** (attempt - 1));
653
+ const retryAt = this.clock.now() + delay;
654
+ const reportedSignature = previous?.reportedSignature ?? '';
655
+ this.failures.set(key, { attempt, retryAt, fingerprint, reportedSignature: signature });
656
+ if (signature !== reportedSignature || fingerprintChanged) {
657
+ this.emitDiagnostic({
658
+ kind,
659
+ severity: 'error',
660
+ phase: kind === 'provider-discovery-failed' ? 'discover' : 'read',
661
+ providerId,
662
+ sessionId,
663
+ attempt,
664
+ retryAt,
665
+ });
666
+ }
667
+ }
668
+ recover(key, kind, providerId, sessionId) {
669
+ const prior = this.failures.get(key);
670
+ if (!prior)
671
+ return;
672
+ this.failures.delete(key);
673
+ this.emitDiagnostic({
674
+ kind,
675
+ severity: 'info',
676
+ phase: 'recover',
677
+ providerId,
678
+ sessionId,
679
+ attempt: prior.attempt,
680
+ });
681
+ }
682
+ emitDiagnostic(diagnostic) {
683
+ try {
684
+ this.options.onDiagnostic?.(diagnostic);
685
+ }
686
+ catch {
687
+ // Diagnostics are observational and never affect collection isolation.
688
+ }
689
+ }
690
+ key(providerId, sessionId) {
691
+ return `${providerId}\0${sessionId}`;
692
+ }
693
+ }
694
+ /**
695
+ * Parse cache bounded by entry count and approximate bytes. Insertion order
696
+ * doubles as recency: a hit re-inserts, so the first entry is always the
697
+ * least recently observed. Entries observed in the current pass are never
698
+ * evicted by that pass, so one oversized pass cannot thrash itself.
699
+ */
700
+ class BoundedCache {
701
+ maxEntries;
702
+ maxBytes;
703
+ estimate;
704
+ entries = new Map();
705
+ totalBytes = 0;
706
+ constructor(maxEntries, maxBytes, estimate) {
707
+ this.maxEntries = maxEntries;
708
+ this.maxBytes = maxBytes;
709
+ this.estimate = estimate;
710
+ }
711
+ get size() {
712
+ return this.entries.size;
713
+ }
714
+ get(key, passId) {
715
+ const entry = this.entries.get(key);
716
+ if (!entry)
717
+ return undefined;
718
+ this.entries.delete(key);
719
+ this.entries.set(key, entry);
720
+ entry.passId = passId;
721
+ return entry;
722
+ }
723
+ set(key, entry, passId) {
724
+ this.delete(key);
725
+ const bytes = this.estimate(entry.value);
726
+ this.entries.set(key, { ...entry, bytes, passId });
727
+ this.totalBytes += bytes;
728
+ this.evict(passId);
729
+ }
730
+ delete(key) {
731
+ const entry = this.entries.get(key);
732
+ if (!entry)
733
+ return;
734
+ this.totalBytes -= entry.bytes;
735
+ this.entries.delete(key);
736
+ }
737
+ keys() {
738
+ return this.entries.keys();
739
+ }
740
+ [Symbol.iterator]() {
741
+ return this.entries.entries();
742
+ }
743
+ clear() {
744
+ this.entries.clear();
745
+ this.totalBytes = 0;
746
+ }
747
+ evict(passId) {
748
+ while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) {
749
+ // Untouched entries always precede entries touched in this pass, so
750
+ // a protected first entry means every remaining entry is protected.
751
+ const first = this.entries.entries().next();
752
+ if (first.done || first.value[1].passId === passId)
753
+ return;
754
+ this.delete(first.value[0]);
755
+ }
756
+ }
757
+ }
758
+ function defaultValueBytes(value) {
759
+ const json = JSON.stringify(value);
760
+ return typeof json === 'string' ? json.length : FALLBACK_VALUE_BYTES;
761
+ }
762
+ function compact(diagnostic) {
763
+ const result = {};
764
+ for (const [key, value] of Object.entries(diagnostic)) {
765
+ if (value !== undefined)
766
+ result[key] = value;
767
+ }
768
+ return result;
769
+ }
770
+ /**
771
+ * Adapt a provider using path-only discovery and independent adapter reads, so
772
+ * one malformed session cannot reject discovery of its healthy siblings.
773
+ */
774
+ export function observedSessionSourceFromProvider(provider, cwd, options = {}) {
775
+ const adapter = options.observationOnly
776
+ ? createProviderSessionAdapterV1(provider, { observationOnly: true })
777
+ : createProviderSessionAdapterV1(provider);
778
+ const referenceFromPath = (sessionPath, sessionId = provider.getSessionId(sessionPath), suppliedParts) => {
779
+ const metadata = suppliedParts ? null : provider.getSessionMetadata?.(sessionPath);
780
+ const parts = suppliedParts ??
781
+ fileFingerprintParts(sessionPath) ??
782
+ (metadata ? { sizeBytes: 0, mtimeMs: metadata.mtime.getTime() } : null);
783
+ return {
784
+ sessionId,
785
+ sourceKey: sessionPath,
786
+ fingerprintHint: parts ? fingerprintString(parts) : undefined,
787
+ fingerprintParts: parts ?? undefined,
788
+ };
789
+ };
790
+ const referenceFromInfo = (info) => {
791
+ const mtimeMs = info.mtime.getTime();
792
+ const suppliedParts = info.sizeBytes !== undefined && Number.isFinite(mtimeMs)
793
+ ? { sizeBytes: info.sizeBytes, mtimeMs }
794
+ : undefined;
795
+ return referenceFromPath(info.path, info.sessionId ?? provider.getSessionId(info.path), suppliedParts);
796
+ };
797
+ const discover = async (discoverOptions = {}) => {
798
+ if (provider.listSessionFilesAsync) {
799
+ try {
800
+ const files = await provider.listSessionFilesAsync(cwd, {
801
+ limit: discoverOptions.limit,
802
+ since: discoverOptions.since,
803
+ stats: discoverOptions.stats,
804
+ // Appends do not bump a directory's mtime; a full pass re-checks
805
+ // files modified recently so a missed watch event cannot hide a
806
+ // live session's growth from a cached listing.
807
+ revalidateRecent: true,
808
+ });
809
+ return files.map(referenceFromInfo);
810
+ }
811
+ catch {
812
+ // Retain compatibility with third-party providers whose optional async
813
+ // enumerator is temporarily unavailable.
814
+ }
815
+ }
816
+ return provider.findAllSessions(cwd).map((sessionPath) => {
817
+ const metadata = provider.getSessionMetadata?.(sessionPath);
818
+ const parts = fileFingerprintParts(sessionPath) ??
819
+ (metadata ? { sizeBytes: 0, mtimeMs: metadata.mtime.getTime() } : null);
820
+ return {
821
+ sessionId: provider.getSessionId(sessionPath),
822
+ sourceKey: sessionPath,
823
+ fingerprintHint: parts ? fingerprintString(parts) : undefined,
824
+ fingerprintParts: parts ?? undefined,
825
+ };
826
+ });
827
+ };
828
+ const source = {
829
+ providerId: provider.id,
830
+ capabilities: adapter.capabilities,
831
+ discover,
832
+ async read(reference) {
833
+ if (!reference.sourceKey)
834
+ throw new Error('session source unavailable');
835
+ return adapter.read(reference.sourceKey, cwd);
836
+ },
837
+ refreshCached(reference, cached, observedAt) {
838
+ const mtimeMs = reference.fingerprintParts?.mtimeMs ?? Date.parse(cached.observedAt);
839
+ const activity = refreshSessionActivityState(cached.activity.value, mtimeMs, Date.now(), getObservedActivityReason(cached));
840
+ return {
841
+ ...cached,
842
+ activity: { ...cached.activity, value: activity },
843
+ observedAt,
844
+ };
845
+ },
846
+ subscribe(listener, subscribeOptions = {}) {
847
+ return subscribeToProviderRoot(provider, listener, subscribeOptions.pollIntervalMs ?? 0);
848
+ },
849
+ dispose: () => adapter.dispose(),
850
+ };
851
+ if (provider.statWatchedSessionFile) {
852
+ const statWatchedSessionFile = provider.statWatchedSessionFile.bind(provider);
853
+ source.resolveReference = async (signal) => {
854
+ if (!signal.root || typeof signal.filename !== 'string' || signal.filename.length === 0) {
855
+ return { status: 'unknown' };
856
+ }
857
+ const resolved = await statWatchedSessionFile(signal.root, signal.filename, cwd || undefined);
858
+ switch (resolved.status) {
859
+ case 'present':
860
+ return { status: 'present', reference: referenceFromInfo(resolved.file) };
861
+ case 'missing':
862
+ return { status: 'missing', sessionId: resolved.sessionId };
863
+ case 'ignored':
864
+ return { status: 'ignored' };
865
+ default:
866
+ return { status: 'unknown' };
867
+ }
868
+ };
869
+ }
870
+ return source;
871
+ }
872
+ function parseFingerprintParts(value) {
873
+ if (!value)
874
+ return null;
875
+ const match = value.match(/^([0-9]+):([0-9]+(?:\.[0-9]+)?)$/);
876
+ if (!match)
877
+ return null;
878
+ const sizeBytes = Number(match[1]);
879
+ const mtimeMs = Number(match[2]);
880
+ return Number.isFinite(sizeBytes) && Number.isFinite(mtimeMs) ? { sizeBytes, mtimeMs } : null;
881
+ }
882
+ function contentTimestamp(value) {
883
+ if (!value || typeof value !== 'object')
884
+ return null;
885
+ const candidate = value.contentObservedAt;
886
+ return typeof candidate === 'string' ? candidate : null;
887
+ }
888
+ function subscribeToProviderRoot(provider, listener, pollIntervalMs) {
889
+ const watchers = new Set();
890
+ let pollTimer = null;
891
+ let roots;
892
+ try {
893
+ roots = provider.getWatchRoots?.() ?? [provider.getProjectsBaseDir()];
894
+ }
895
+ catch {
896
+ roots = [];
897
+ }
898
+ for (const root of new Set(roots)) {
899
+ try {
900
+ const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, filename) => listener({
901
+ trigger: 'event',
902
+ root,
903
+ eventType,
904
+ filename: filename === null || filename === undefined ? null : String(filename),
905
+ }));
906
+ watchers.add(watcher);
907
+ watcher.on('error', () => {
908
+ watchers.delete(watcher);
909
+ try {
910
+ watcher.close();
911
+ }
912
+ catch {
913
+ // Catch-up polling remains active.
914
+ }
915
+ });
916
+ }
917
+ catch {
918
+ // Polling remains the documented fallback.
919
+ }
920
+ }
921
+ if (pollIntervalMs > 0) {
922
+ pollTimer = setInterval(() => listener({ trigger: 'poll' }), pollIntervalMs);
923
+ pollTimer.unref?.();
924
+ }
925
+ return {
926
+ dispose: () => {
927
+ for (const watcher of watchers)
928
+ watcher.close();
929
+ watchers.clear();
930
+ if (pollTimer)
931
+ clearInterval(pollTimer);
932
+ },
933
+ };
934
+ }
935
+ function failureSignature(error) {
936
+ if (error instanceof Error)
937
+ return `${error.name}\0${error.message}`;
938
+ return typeof error === 'string' ? error : Object.prototype.toString.call(error);
939
+ }